ブラウザに本物の夜空を描く第3節/全10節
座標系とシーンの姿勢
三つの座標系が同居する理由と、観測者を原点に置く判断、そして天球グループを回す一つの行列をどこから得ているか。
天文計算で、ある天体がどの方向に見えるか(観測者の地平線に沿って、真北を0度として東回りに測った角度です。東が90度、南が180度、西が270度にあたります。と観測者の地平線から測った、天体の上向きの角度です。0度が地平線、90度が真上の天頂で、負の値は地平線の下にあることを意味します。)が出たとして、それを3Dシーンのどこに置けばよいのでしょうか。この節は四つの問いに答えます。座標系がなぜ三つも必要なのか。地球の中心ではなく観測者をシーンの原点に据えたのはなぜか。星空を回す回転数を縦横に並べた表で、3Dでは回転や移動といった座標の変換をひとまとめに表すのに使います。変換を続けて掛け合わせれば、いくつもの段階を1つの行列にまとめられます。を自前で組み立てず、大気の描画を担うライブラリが持っている行列を借りているのはなぜか。そして、毎動く絵を作るときの1コマです。滑らかに見せるには毎秒60コマ前後を描き続ける必要があり、1コマぶんの持ち時間は16ミリ秒ほどしかありません。の処理の順序がなぜ入れ替えられないのか。
同居する三つの座標系
Asterariumのシーンには三つの座標系が同時に存在し、それぞれ担当が違います。混同するとバグになるので、境界をはっきり決めてあります。星カタログが使う天体の座標がいつ時点のものかを示す基準時刻、すなわち元期の一つで、世界時2000年1月1日正午を指します。星のカタログの座標は、多くがこの元期で書かれています。地球の赤道と自転軸を空へ延ばして張る座標系です。位置は、経度にあたる赤経と緯度にあたる赤緯の2つの角度で表します。地球の自転とともには回らないので、恒星の値はほぼ一定です。は、空の上で、天の赤道(地球の赤道を空へ延ばした線)と太陽の1年の通り道が交わる2点のうち、太陽が南から北へ横切る方の点です。空で経度にあたる角度は、この点を0度の起点として測ります。と天の北極の方向を基準に軸を取ります。Asterariumの3Dシーンで物の位置を表す座標系で、1単位がおよそ1メートル、原点は観測者、軸の取り方はNUE(North-Up-East、北・上・東)です。ここでの距離は実際の距離ではなく描画のための便宜的な値で、星は半径1000(約1km)の球面に、太陽・月・惑星はその外側の200万(約2,000km)に置きます。描かれる大気の層の厚さは約6万単位(60km)です。とAsterariumの3Dシーンが使う、座標軸の取り方の名前です。観測者を原点に置いたうえで、X軸を真北、Y軸を天頂、Z軸を真東に向けます。名前はNorth・Up・Eastの頭文字です。は同じものの別名で、前者は役割の名、後者は軸の取り方の名です。北・上・東をX・Y・Zの順に並べた三本の軸は右手系(右手の親指・人差し指・中指をそれぞれX・Y・Zに当てられる軸の並び方)をなし、ブラウザで3Dを描くWebGLを扱いやすくする、定番のJavaScriptライブラリです。シーン、カメラ、形状、材質といった概念で場面を組み立て、描画命令の細部を引き受けてくれます。が前提とする向きの取り方と一致します。
| 座標系 | 原点 | 軸の向き | 時刻に依存するか | 使う場所 |
|---|---|---|---|---|
| J2000赤道座標 | 形式的(方向だけを表す) | +Xが春分点、+Zが天の北極 | しない(2000年初頭の姿勢に固定) | 星カタログのデータ、星座線、天の川、赤道グリッド |
| ECEF(地球中心・地球固定) | 地球の中心 | 地球に固定され、自転とともに回る | する | 大気散乱ライブラリ@takram/three-atmosphereの内部 |
| NUE(北・上・東)ワールド座標 | 観測者 | +Xが真北、+Yが天頂、+Zが真東 | しない(観測地が変わらないかぎり) | three.jsのシーンとカメラ。画面に映るものすべて |
三つのうち二つは外から決まっています。星カタログはJ2000赤道座標で配布され、太陽の光が大気中の分子や粒子にぶつかって、あらゆる向きへ散らばる現象です。青い光ほど強く散らばるので昼の空は青く、低い太陽の光は長い距離の大気を通るので朝夕は赤くなります。ライブラリは自分の計算を地球に固定した直交座標系で、原点が地球の中心、Z軸が自転軸、X軸が経度0度の方向を向きます。名前はEarth-Centered, Earth-Fixedの略です。で行い、どちらの仕様もこちらでは変えられません。自由に決められたのは残る一つ、シーンとカメラが使うワールド座標だけで、そこに観測者を原点として北・上・東を軸に取るNUEを選びました。地平線はY=0の平面、方位はatan2(z, x)、高度はasin(y)になります。
観測者を原点に置く理由は浮動小数点の精度
シーンの原点を地球の中心ではなく観測者に置いたのは、GPUに送る座標の精度を守るためです。このシーンの1単位は1メートルに相当します。恒星は半径1000(1km)の球面に、太陽・月・惑星は距離2,000,000(2,000km)に置かれます。現実とは逆に恒星のほうが月より手前にあるのは、恒星は向きだけを表せばよいのに対し、太陽・月・惑星は大気の層より外に置く必要があるからです(内側に置くと、明るい昼空を背にした黒い円に見えます。大気散乱ライブラリは画面を細かく区切った1つ1つの点で、絵はその集まりでできています。色はこの単位で決まるので、描画の重さも塗るべき画素の数でおおよそ決まります。ごとの描画で、カメラからその画素に見えているものまでの距離のことです。どちらが手前かの判定は、この値を比べて行います。からカメラまでの空気の通り道の長さを求めて散乱光を足すので、通り道の短い天体には散乱光がほとんど乗らないからです。詳しくは「深度の規約と、踏んだ不具合」の節にあります)。恒星のほうは大気の層の内側に残りますが、それで困らないのは星を描くのが夜だけだからです。層の内側にあるものには昼のあいだ大気の散乱光と減光(大気が光を弱めること)が乗りますが、星は太陽が地平線の下にありながら、空がまだ明るい時間帯です。太陽が沈むほど深くなり、夕方は高度−6度までの市民薄明、−12度までの航海薄明、−18度までの天文薄明の順に進みます。朝は逆に、天文薄明から航海薄明、市民薄明をたどって日の出に至ります。のあいだAsterariumで、空がどれだけ夜になっているかを0から1で表す値です。太陽の高度が0度のとき0、空が完全に暗くなる−18度で1になり、星・星座線・天の川・深宇宙天体の印の見え方をまとめて決めます。月は不透明な円盤のまま、その明るさをこの値を0.25から1の範囲に縮めた値で決めるので昼でも薄く見え、惑星は太陽高度についての別のしきい値で明るいうちから現れます。で薄れ、昼には消えています。薄明中に大気の減光を受けること自体は、星が自然に薄れて見える効果として利用しています(「大気と星の描画」の節にあります)。
原点を地球の中心に置くと、足元の地面の位置は約0.5メートル刻みでしか表せなくなります。three.jsが頂点の位置を32ビット浮動小数点数でGPUに渡すからで、この形式で表せる値は飛び飛びです。隣り合う二つの表現可能な値の差を刻み幅と呼ぶと、天体を置く2,000,000(2,000km)での刻み幅は0.125メートル、地球の半径にあたる6,370,000では0.5メートルになります。刻み幅は長さそのもの、つまり絶対量なので、座標の値が大きいほど粗くなるわけです。導出は次のとおりです。この形式は浮動小数点数で、有効桁を持っている部分です。桁数が決まっているので、表せる刻み幅は値の大きさに比例して粗くなります。32ビットの形式では24ビットぶんの有効桁を持ちます。(浮動小数点数の有効桁を持つ部分)が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メートルという刻み幅は面どうしの間隔に対して大きな割合を占めるので、手前にあるはずの面が奥と判定されることが起こり得ます。前後がフレームごとに入れ替わってちらつくこの現象は、深度の奪い合い(前後がほぼ同じ距離にある2つの面について、どちらを手前に描くかの判定が画素ごと、あるいはフレームごとに入れ替わり、境目がちらついて見える現象です。原因は距離の差を表しきれないことで、深度(カメラから見た奥行き)の刻みが粗い場合にも、頂点の座標が大きくて丸め誤差が増える場合にも起きます。)と呼ばれます。ここで言うz-fightingは、画素ごとに、いまそこに描かれているものまでの距離を覚えておくGPU上の記憶領域です。これがあるおかげで、手前のものが奥のものを隠せます。持っている値は距離そのものではなく、0から1に写した正規化された深度で、最も遠い状態が1.0です。(画素ごとに深度の値を記録する場所)の精度の話ではありません。深度の記録は充分に細かくても、頂点の座標そのものが飛び飛びの値に丸められることで前後が入れ替わる場合を指しています。
遠くの天体のほうは、原点を観測者に移せば気にしなくてよくなります。天体は方向ベクトルに距離を掛けて置くので、座標の刻みは角度の刻みとして効くからです。距離2,000kmでの刻み幅0.125メートルは、角度にすれば約0.013角度の単位で、1度の3600分の1です。満月の見かけの直径がおよそ1800秒角にあたります。にすぎません。このアプリのいちばん深いズームは垂直視野1度で、画面の高さが1,000画素なら1画素が約3.6秒角にあたりますから、この誤差は1画素の百分の一にも届きません。加えて、方位と高度がそのまま座標軸に対応するので、観測者の地平線を基準にした座標系で、位置を、真北から測る方位と、地平線から測る高度という2つの角度で表します。同じ星でも、場所や時刻が変われば値が変わります。からシーンの方向ベクトルへの変換に回転が一切要りません。
代償は、大気散乱ライブラリとのあいだに座標系の変換を一枚挟むことです。その一枚がワールド座標からECEFへの変換行列worldToECEFMatrixで、観測者の緯度・経度・標高だけから決まります。作り方は二段で、まず地球上の位置を、緯度・経度・標高の3つで表す言い方です。標高は真球ではなく、地球のわずかに扁平な形を近似した回転楕円体の面から測ります。(緯度・経度・標高で位置を表す形式)をWGS84楕円体(地球の扁平な形を回転楕円体で近似した標準的な測地系)上のECEF位置に変換し、次にその点を原点として北・上・東をある座標系の軸の向きを決める、たがいに直交する長さ1のベクトルの組です。この組を列に並べた行列を掛けると、その座標系での表し方に移せます。とする行列を組みます。この行列を計算するのは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からワールド座標への回転です。逆行列は一般には手間のかかる計算ですが、ここでは回転部を取り出して転置するだけで済みます。北・上・東という互いに直交する長さを1にそろえた、向きだけを表すベクトルです。天体がどちらに見えるかのように、距離ではなく向きだけが要る量を扱うのに使います。を並べた行列は各軸が互いに直角で、長さが1にそろっている行列です。この形の行列は逆行列が転置、つまり縦横を入れ替えたものに等しく、逆向きの変換を計算なしで得られます。であり、直交行列の逆行列は転置に等しいからです。この「逆行列を転置で代用してよい」という前提から生まれる制約のうち、人が守らなければならないのは列どうしが直角であることだけです。もう一つの制約である「行列に拡大縮小を含めない」ほうは、回転部を取り出すthree.jsの関数extractRotationが各列の長さを1にそろえるので、実装が自動的に落とします。直角のほうを検査する仕組みは無く、規約として守られているだけです。平行移動の成分(観測者のECEF位置、約6,370km)を捨てるのは、変換したいのが位置ではなく方向だからです。
天球グループを回す行列を大気ライブラリから借りる
星空全体の姿勢は、毎フレーム一つの行列に集約されます。恒星、星座線、天の川、恒星と太陽系の天体を除いた、星雲・星団・銀河といったぼんやり広がって見える天体の総称です。多くは肉眼では見えず、双眼鏡や望遠鏡で見ます。のマーカー、赤道グリッドは、Asterariumの3Dシーンで、天球を表しているthree.jsのGroup、つまり子オブジェクトをまとめて動かすための入れ物です。恒星・星座線・天の川・深宇宙天体の印などを子に持つので、この入れ物の姿勢を表す行列を書き換えると、子が全部いっしょに回ります。(天文学で、星の見える向きを表すために考える、観測者を中心とした仮想の球面です。距離は持たず、星までの本当の距離は捨てて、どの向きに見えるかだけを扱います。この記事では、その天球を表す3Dシーンの入れ物も同じ名前で呼び、シーンの1単位が約1メートルにあたるので、半径1000、およそ1kmの球面に星を置いています。を表す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)
}自前で春分点(天の赤道と太陽の通り道が交わる点)を基準にした地球の自転角を、時刻の形で表したものです。角度を時間の単位で言い換えたものなので、24時間が360度、1時間が15度にあたります。星が空のどこに見えるかは、太陽ではなくこの値で決まります(暦の上での話で、実装がこの値を直接使うかどうかは別です)。から回転を組み立てる道もありました。実際、Asterariumで、three.jsもReactも状態管理の仕組みも参照しない、天文計算などのモジュール群を指す呼び名です。ブラウザを立ち上げずに実行できるので、入力を与えて戻り値を確かめるだけのテストが書けます。 — three.jsもReactも参照しない天文計算のモジュール群 — には地方恒星時の関数(グリニッジ恒星時をライブラリから取り、観測者の経度を足すもの)があり、球面三角法の式は数十行で書けます。それでも借りる側を選んだ決め手は、精度ではなく整合性です。
画面には二種類の描画結果が同時に映っています。ライブラリが描く空(昼の青、薄明のグラデーション、夜の暗さ)と、Asterarium自身が描く星空です。前者は太陽の方向で決まり、その太陽方向はライブラリが日付から計算します。もし星の日周運動を別実装で計算すると、地球の自転軸の向きが、約2万6000年かけて円を描くようにゆっくり変わる現象です。空の座標の起点である春分点もこれにともなって動くため、星の座標も少しずつ変わります。モデルの違いや平均恒星時と視恒星時の選択といった細部から、二つはずれ得ます。同じモデルを選べばずれは小さいでしょうが、片方だけを更新したり実装の細部が食い違ったりすれば、空が白み始める方向と星が沈む方向が合わなくなります。原因を症状から辿りにくい種類の不具合です。天球グループの姿勢に限れば、決める実装は一本だけなので食い違いは起こりません。
太陽の方向は、用途の違う二つの経路で別々に求めています。画面に描く太陽・月・惑星の位置はAsterarium自身の天文計算から取り、地球の大気が光を曲げるために、天体が実際の位置より高く見える現象です。持ち上がる量は地平線付近で最も大きく、約34分角(0.57度)に達します。(大気の屈折で天体が実際より高く見えるずれ)を含みます。星を薄明でフェードさせるための太陽高度のほうは大気散乱ライブラリが公開する太陽方向から取り、こちらは大気差を含みません。以下の段落はその二つを順に見ていきます。
太陽・月・惑星は天球グループの外にあります。これらは毎フレーム、大気差込みの地平座標として計算され、方位と高度からそのままワールド座標の方向ベクトルになります。J2000からの回転を通す必要がなく、むしろグループに入れると行列が二重に掛かって位置が壊れます。
大気散乱ライブラリもECEFでの太陽・月の方向を公開していますが、そちらは大気差を含まない幾何学的な方向です。大気差は地平線付近で約34角度の単位で、1度の60分の1、すなわち60秒角です。(0.57度)あります。太陽は日周運動で1分間に約15分角動き、沈む速さはその一部で緯度と季節によりますが、日本のような中緯度の日没では1分間におおむね10分角前後です。34分角をこの速さで割ると、ライブラリの方向をそのまま使った場合の日没の絵は約3分ずれます。経路を分けたのは大気差を入れるための意図的な判断です。二つの経路の差は、この大気差と、二つの実装が使う天体の位置を時刻ごとに並べた表、またはその位置を計算するモデルのことです。どの天体暦を使うかで、同じ時刻の位置がわずかに違ってきます。モデルの違いの和です。後者の違いは、どちらも現代の天体暦に基づくため秒角の桁とみられます。
ただし星を薄明でフェードさせるための太陽高度だけは、ライブラリ側の太陽方向から取っています。星の消え方は「空がどれだけ明るいか」に連動すべき量なので、空を描いている当人の太陽に合わせています。その代わり、画面に描かれる太陽(大気差込み)とフェードの基準(大気差なし)は約34分角ずれたままです。フェードは太陽高度0度から−18度への変化に沿って進むので、ずれは日没の絵のずれと同程度、数分の範囲に収まります。空の明るさとの一致を優先した割り切りです。
毎フレームの順序は依存関係で決まっている
3D描画ライブラリthree.jsの場面を、Reactの部品として書けるようにするライブラリです。ReactがHTMLの要素ではなく3Dの物体を組み立て直します。はuseFrameという、描画のたびに呼ばれるフックを提供します。このシーンでそれを使うコンポーネントはただ一つで、その中の順序は入れ替えできません。順に、Asterariumで、場所・日時・カメラの向き・表示の設定をひとまとめに記録した場面をいくつも並べ、順に自動で見せていく再生機能です。1つの場面をステップと呼び、切り替え方と滞在時間をステップごとに決めます。の更新、時刻の前進、大気の更新、天球グループの姿勢の更新、そして夜係数の計算と太陽・月・惑星の配置です。
- ツアーの更新が時刻の前進より先にあるのは、ツアーが飛び先の絶対時刻を書き込んだ結果を、同じフレームの大気と天球グループが読めるようにするためです。逆にすると反映が1フレーム遅れます。
- 大気の更新は時刻が確定してからでなければ計算できません。太陽と月の方向も、J2000からECEFへの回転行列も、日付を入力とするからです。
- 天球グループの姿勢はその回転行列を読むので、大気の更新より後でなければ1フレーム前の値を使うことになります。
- 夜係数と天体の配置は、確定した時刻と更新済みの太陽方向の両方に依存します。
順序と並んでもう一つの規約が、このループで新しいオブジェクトを一切作らないことです。行列とベクトルはマウント時に一度だけ確保して使い回し、地平座標をワールド座標の方向ベクトルに直す関数も、新しいベクトルを返さずに引数のベクトルへ書き込みます。毎フレーム捨てられるオブジェクトを積み上げると、ガベージコレクションがまとまって走った瞬間にフレームを落としかねないからです。また、大気の材質(描き方の設定)が準備されるまで、ライブラリの状態を取り出す関数は何も返しません(nullが返ります)。その間ループは何もせず次のフレームに委ねます。「まだnullが返る」のはまだ準備ができていないということで、この判定をシーン全体で共有しています。
つまり、変えられない二つの座標系に挟まれた自由度はワールド座標の定義だけで、そこに観測者原点のNUEを選んだことが、浮動小数点の精度と、地平座標の素直な扱いと、天球グループ全体を回す一つの行列をまとめて手に入れる判断になりました。この節の判断は、サーバー側で動くコードを持たないという制約から直接には出ていません。ただし計算をすべてブラウザで行う以上、精度の問題もブラウザ側で起きます。座標を32ビット浮動小数点数で受け取るGPUに合わせて原点を選ぶ、という形でそれは現れました。