[Astro] #190 トップページの空を「今日の空」の計算に置き換える — 星・太陽・月と、視点の切り替え

[Astro] #190 トップページの空を「今日の空」の計算に置き換える — 星・太陽・月と、視点の切り替え

はじめに

前に、日課アプリとして「今日の空」を作りました。場所と日付から、太陽・月・星の位置を計算して、全天の 3D で見せるアプリです。

トップページの背景は、これまで、スカイボックスの絵(テクスチャ)を貼っているだけでした。そこで今回は、この背景の空を、「今日の空」の計算に置き換えました。

この記事は、その実装のまとめです。

変えたこと

  • 右クリックメニュー(SKY)に、スカイボックスの項目「Sky Today」を足しました。選ぶと、背景が、いまの時刻・場所の空になります
  • 星・天の川・太陽・月・惑星の計算は、「今日の空」のコードをそのまま使います(public/tool/sky-today/)
  • SKY メニューに、星座線・星の名前・天の川・流れ星の入切と、場所の選択を足しました
  • 固定だったカメラに、視点を 3 つ足しました(DEFAULT / DOME / SKY)。メニューの CAMERA、キー、ターミナルのコマンドで切り替えます
  • 時間帯(夕方・夜)の「空の色の掛け算」は、Sky Today のあいだは止めます
トップページの SKY 視点。ステージの機械のシルエットの向こうに、天の川と星が見える

視点変更

トップページの SKY 視点。ステージの機械のシルエットの向こうに、天の川と星が見える トップページの SKY 視点。ステージの機械のシルエットの向こうに、天の川と星が見える トップページの SKY 視点。ステージの機械のシルエットの向こうに、天の川と星が見える

動画(PC)

動画(WebXR/VR)

方針: 空を差し替えて、光は元の絵に任せる

トップページの空(WiredSkybox)は、半径 500 の球に絵を貼ったものです。同時に、その絵を環境マップ(<Environment map>)としても使っています。ステージは金属の素材なので、まわりの光を映す環境マップがないと、真っ黒になります。

そこで、次のように分けました。

  • 見える空: Sky Today の計算で描く
  • 光(環境マップ): 今までの絵に任せる

スカイボックスの一覧(skybox.json)には、新しい項目を足しました。path は、環境マップ用の絵のまま残します。live に sky-today と書くと、見える空だけが差し替わります。VR のメニューや MR の側も、この一覧を読むので、path が有効な項目にしておきました。

{
  "id": "sky_today",
  "name": "Sky Today",
  "path": "(環境マップに使う、既存の絵)",
  "live": "sky-today"
}

WiredSkybox には background の指定を足しました。false のときは、空の球を出さず、環境マップだけを置きます。

<WiredSkybox
  rotationSpeed={currentStageData.rotationSpeed}
  data={currentSkyboxData}
  background={(currentSkyboxData as any).live !== 'sky-today'}
/>
{(currentSkyboxData as any).live === 'sky-today' && <WiredSkyToday />}

空を組み立てる

新しい部品 WiredSkyToday.tsx が、空を作ります。

「今日の空」のコードを読み込む

「今日の空」の本体(sky-scene.js)は、public の下にある素の JavaScript です。これを import() で読むと、Vite の開発サーバーで失敗します。そこで、planetarium-loader.js を <script type="module"> で読み込み、部品を window.__skyToday に入れる形にしました。VR のプラネタリウム(WiredPlanetarium.tsx)と同じ方法です。

空の組み立ては、次の関数です。

createSkyScene(THREE, { calc, data, S, pixelRatio, onMode, decor, labelScale, bodyScale })

S は、場所・時刻・表示の入切を持つ状態です。毎フレーム、tick(new Date(), dt) で、いまの時刻の空に更新します。時刻は、PC の時計です。

decor、labelScale、bodyScale は、今回、足した引数です。既定値は、それぞれ true、1、1 で、「今日の空」のページと VR のプラネタリウムの見た目は、変わりません。

  • decor: false … 地面の円盤、緑の輪、東西南北の文字を出さない(トップページには、ステージがあるため)

カメラについてくる

空は、無限に遠い背景に見えるべきです。そこで、空の root を、毎フレーム、カメラの位置に合わせます。地平線は、カメラの高さになります。微調整のために、weather.json に、次の値を置きました。

  • yOffset … 地平線の高さの調整(メートル)
  • yawDeg … 空を回す角度。0 のとき、初期のカメラの正面が北

座標は、x が東、y が上、z が南です。カメラの初期の向きは、-z(北)です。

地平線の下を隠す

「今日の空」の空は、地平線の下も、星を描きます。ページでは、地面の円盤が隠していました。トップページでは、地面を出さないので、地平線の下の星が、ステージの隙間から見えてしまいます。

そこで、下半分の半球(半径 90)を、「ふた」として置きました。星・天体は半径 95〜100 にあるので、その内側です。

  • 色は、空の地平線の色(domeMat の uHor)を共有します。地平線の色が変わると、ふたも同じ色になります
  • 真下に向かって、少しずつ暗くします(capDark、0 で暗くしない)
float k = clamp(-normalize(vP).y, 0.0, 1.0);
gl_FragColor = vec4(uHor * (1.0 - uDark * k), 1.0);

色を合わせる

最初に動かしたとき、色が、「今日の空」のページと違いました。トップページの描画は、ACES のトーンマッピングが掛かる設定です。「今日の空」のページは、掛けていません。

そこで、空の root の中の素材を、すべて toneMapped = false にしました。空だけが、ページと同じ色になります。ステージとアバターは、今までどおりです。

文字と天体の大きさ

星座名・星の名前・星雲の名前は、文字を貼った板(スプライト)で、世界座標での大きさが、固定です。「今日の空」のページと VR は、画角が広いので、ちょうどいい大きさでした。トップページのカメラは、画角が 35° と狭く、同じ大きさでも、画面では大きく見えます。最初の画面では、文字が画面を覆うほど大きくなりました。

そこで、倍率を 2 つ足しました。

  • labelScale … 星座名・星の名前・天体の名前
  • bodyScale … 太陽・月・惑星の丸

トップページの値は、weather.json の skyToday に置きます。初期値は、目で見て、labelScale が 0.4、bodyScale が 0.5 にしました。

SKY メニューの設定

右クリックメニューの SKY タブで、Sky Today を選ぶと、次の項目が出ます。

  • STAR_LINES(星座線と星座名)、STAR_NAMES(星の名前)、MILKY_WAY(天の川・星雲・星団)、METEORS(流れ星)
  • PLACE … SKY_TODAY(「今日の空」のページで、最後に使った場所)または TOKYO

設定は、localStorage の wired_sky_today に保存します。再読み込みしても、残ります。初期値は、weather.json の skyToday の showCons、showNames、showMW、showMeteors で決められます。一度メニューで切り替えた項目は、保存した値が優先です。

場所の SKY_TODAY は、「今日の空」のページが sky-today.v1 に保存した場所を読みます。保存がなければ、東京です。場所を切り替えると、太陽・月・惑星と、空の色を、新しい場所で計算し直します。

視点の切り替え

トップページのカメラは、ずっと固定でした(位置 [0, 0, 4]、画角 35°)。マウスは、アバターの視線と右クリックに使っていて、カメラを動かす操作がありません。

自由に動かす代わりに、視点をいくつか決めておいて、切り替えることにしました。WiredCameraRig.tsx を新しく作り、ROOM のときだけ置きます。

視点内容
DEFAULT今までの視点(初期のカメラそのもの)
DOME少し遠くから、斜め下に見下ろす
SKY空を見上げる(画角を広げる)

視点は、position(カメラの位置)、target(見つめる点)、fov(画角)で決めます。切り替えると、約 0.8 秒で、なめらかに動きます。着いたら、カメラに触るのをやめて、ほかの処理に任せます。VR / MR の最中は、何もしません。ROOM を出るときは、初期の視点に戻します。

切り替え方

  • メニュー(右クリック)の [<] CAMERA [>]。タブの上にあり、どのタブでも使えます
  • Alt+1〜9 … 並んだ順の 1〜9 番目。ターミナルに文字を打っている最中でも効きます
  • ターミナルのコマンド view [名前 | next | prev | 番号]

ファンクションキーは、使いませんでした。常駐アプリが、グローバルなホットキーとして使っていることがあるためです。ターミナルの入力欄は autoFocus で、ほぼいつもフォーカスされているので、文字のキーも使えません。そこで、Alt と数字の組み合わせにしました。キーは、weather.json の camera.keys で変えられます。

視点を増やす

視点の一覧は、weather.json の camera.views の並びで決まります。名前を足すと、メニューの CAMERA に、並んだ順で出ます。コードは触りません。

"camera": {
  "seconds": 0.8,
  "keys": { "sky": "Alt+3" },
  "views": {
    "dome": { "position": [5, 4, 9.5], "target": [0, -0.4, 0], "fov": 35 },
    "sky":  { "position": [0, 0, 4],   "target": [0, 4, 1],    "fov": 60 }
  }
}

メニューとカメラの部品は、小さな共有部分(utils/cameraView.ts)でつなぎました。カメラ側が、視点の一覧といまの視点を知らせ、メニュー側が、切り替えの依頼を送ります。ROOM でないときは、一覧が空になるので、CAMERA の行が出ません。

値は、コンソールで決める

位置と向きは、見ながら決めるものです。再読み込みなしで試せるように、コンソールに __camTune を出しました。

__camTune.stage()                              // ステージの大きさと中心
__camTune.views.dome.position = [6, 5, 11]     // 視点を書き換える
__camTune.go('dome')                           // その視点へ
__camTune.dump()                               // weather.json に貼れる形で出す

stage() で、ステージ(GLB)の大きさを出すと、約 27.84 × 20.89 × 28.04、中心は [0, -1.2, -1] でした。カメラは、この大きなステージの内側にいます。

時間帯の色との重なり

トップページには、時間帯(朝・昼・夕・夜)の仕組み(WiredTimeOfDay)があります。そのうちの 1 つが、空の色の掛け算です。カメラを中心にした球殻が、背景の色に、時間帯の色(夕方の橙、夜の青)を掛けます。

「今日の空」の空は、時刻と場所から、夕焼けや夜の色を、自分で計算しています。そこに、時間帯の色が掛かると、二重になります。夕方に、空がピンクがかったグレーに見えたのは、これだと推測しています(確認は、これからです)。

そこで、「いま空が Sky Today か」を示す印(skyTodayRuntime.active)を作りました。実行中だけの印で、保存しません。WiredSkyToday が、空ができたら立て、片づけるときに下ろします。WiredTimeOfDay は、印が立っているあいだ、掛け算の殻を出しません。

const tinted = !xr && !skyTodayRuntime.active
  && (cur.sky[0] < 0.997 || cur.sky[1] < 0.997 || cur.sky[2] < 0.997);

画面全体の明るさ、環境光、アバターのスポットライト、環境マップの強さは、今までどおり、時刻で動きます。

詰まったこと

  • Cannot read properties of null (reading 'add')。空を置く場所に、ref を使っていました。同じ場所の WiredSkybox が、Suspense で止まっているあいだ、兄弟の ref が外れて、null になります。自分で作った Group を useMemo で持ち、<primitive object={group} /> で置く形に変えて、直りました
  • 視点を切り替えると、目標と逆を向きました。向きの計算に、カメラではない Object3D の lookAt を使っていました。カメラは -Z が前ですが、カメラ以外のオブジェクトの lookAt は、+Z が目標を向きます。結果、DEFAULT で空と円柱が見え、DOME では、アバターが見えませんでした。同じ位置で、前方と目標の向きの内積を比べると、−1(逆) でした。Matrix4.lookAt(eye, target, up) から、クォータニオンを作る形にして、+1 になりました
  • 霧が OFF なのに、再読み込みで出ました。切り替えのイベントが、シーンが受け取る前に送られていました。状態の初期値で、保存した値を読む形に直しました
  • public の JS を import() すると、Vite の開発サーバーで失敗します。<script type="module"> で読む形にしました
  • 文字が大きすぎました。画角の違いを、考えていませんでした。倍率を足して、weather.json に置きました
  • ブラウザの機能と、重なるキー。F キーを、切り替えに割り当てかけました。常駐アプリのホットキーと重なるので、Alt と数字にしました

未確認のこと・制限

  • メニューの CAMERA の行と、時間帯の色を止める変更は、この記事を書いた時点では、実機で確認していません。夜は __todTune.override = "22:30"、夕方は "17:45" で、時刻を固定して、確かめる予定です
  • 掛け算の殻は、半径 8 の球です。ステージは約 28 の大きさなので、8 より遠い部品も、夜に暗くなっていた可能性があります。止めると、夜に遠くの機械が、明るく見えるかもしれません(構造からの推測です)
  • 太陽の高度で、環境光とスポットライトを動かす連動は、していません。いまは、時刻だけで決めています
  • VR のメニューや MR で、新しいスカイボックスの項目が、正しく動くかは、確認していません
  • 視点の値(位置・向き・画角)は、見ながら決めた値です。ステージの大きさや、カメラの向きに合わせて、調整します
  • 実際の夜空との見比べは、まだです。確認したのは、PC の Chrome です

おわりに

次は、2 つ考えています。

  • 太陽の高度で、環境光とスポットライトを動かす。いまは、時刻だけで、決めています

  • VR のメニューと MR での、新しいスカイボックスの確認

  • 今日の空: https://lain-lab.com/sky-today