[Astro] #167 サイトマップ #03 — VR で星を渡り歩き、パネルと記事ボードで、記事まで読めるところまで

[Astro] #167 サイトマップ #03 — VR で星を渡り歩き、パネルと記事ボードで、記事まで読めるところまで

はじめに

前回までで、3D サイトマップを作って、作り込みました。カテゴリが大きな星、アプリが周りの星、記事が星屑で、クリックでワープします。

#166 の最後に、VR 化の方針を書きました。今回は、その続きです。Meta Quest 3 で、星座の中を渡り歩いて、記事を読むところまで動きました。

今回の要点は次の 4 つです。

  1. 星座の中心に立って、レーザーで星を指して、ワープで渡り歩く
  2. 着いた先で、アプリをパネルで壁のように並べる
  3. アプリの惑星に着くと、カードの右に、記事の本文が出る(記事ボード)
  4. 星の距離を広げたら、迷子感が出た。それは残すことにした

完成画面

VR で、カテゴリに着くと、アプリのパネルが湾曲した壁のように並ぶ アプリの惑星に着くと、カードの右に記事ボードが出る

動画

カテゴリの惑星へ移動

VR で、カテゴリの惑星へ移動

カテゴリの惑星からアプリ記事へ移動

VR で、カテゴリの惑星からアプリ記事へ移動

ブログ記事の星へ移動

VR で、ブログ記事の星へ移動

操作は次のとおりです。

  • ページの「ENTER VR」を押す。星座の中心に立つ
  • コントローラーのレーザーで星を指して、トリガーで移動する
  • カテゴリの星に着くと、そのカテゴリのアプリが、湾曲したパネルの壁で並ぶ
  • アプリの惑星に着くと、そのアプリのカードだけが出て、右に記事の本文が出る。スティックの上下でスクロールする
  • グリップで、中心(ホーム)に戻る

段階に分けて、1 つずつ確かめた

一気に作らず、段階に分けました。1 つ作るたびに、Quest で確かめてから次へ進みました。

段階内容
1VR で世界が見える。中心から、星を指して移動する
2着いたカテゴリのアプリを、パネルで並べる
3aアプリの惑星に着いたら、そのカードだけを出す
3bカードの右に、記事の本文を出す

あとから、ワープの演出と、星の距離の調整を足しました。

段階 1:VR で世界が見える

カメラを動かすのではなく、台を動かす

WebXR では、カメラの位置は、頭の位置そのものです。アプリからカメラを直接動かすことはできません。そこで、カメラとコントローラーを dolly という Group に入れて、動かすのは dolly だけにしました。

const dolly = new THREE.Group();
dolly.add(camera);               // カメラ
dolly.add(controller0, controller1); // コントローラー
scene.add(dolly);

頭は dolly の中で自由に動けるので、目的の星に頭を置くときは、頭の位置の分を引いて dolly の位置を決めています。

VR のとき、OrbitControls は使いません。OrbitControls.update() は、カメラの位置を上書きするためです。

世界の縮尺

最初は、星座の世界は半径が約 620 でした。これを、VR の 1 メートルに対応させると、巨大です。縮尺をかけて小さくしました。

最初は 0.01 にしたら、ミニチュアのように見えました。Quest で確かめて、0.04 に上げました。

世界全体に縮尺をかけるので、後光の大きさや、点の大きさも、一緒に縮めます。点の大きさ(size)は、視点から見た長さなので、世界を縮めたときは、同じ倍率で縮めないと、星が巨大に見えます。

当たり判定は、角度で取る

レーザーと星の当たり判定は、距離ではなく、角度で取りました。レーザーの向きと、星の方向がなす角度が、許容角度以内で、いちばん近い星を選びます。

const t = vx * d.x + vy * d.y + vz * d.z; // レーザーの向きへの射影
const cs = t / Math.sqrt(vx * vx + vy * vy + vz * vz);
if (cs <= cth) return;                    // 許容角度の外
const r = (1 - cs) / (1 - cth);           // 小さいほど、正確に指している

2D 版で、画面の座標が近い星を探していたのと、同じ考え方です。小さい点を、レーザーで指すのは難しいので、許容角度を、カテゴリ 5 度・アプリ 3 度・記事の星 2 度としました。

移動

最初の 1 回は、暗転して、瞬間移動して、明るくします。2 回目以降は、ワープです。

ワープに入る前の瞬間移動は、VR に入った直後に、いきなり景色が流れて酔わないようにするためです。prefers-reduced-motion のときは、いつも瞬間移動にしています。

段階 2:パネルの壁

カテゴリの星に着くと、そのカテゴリのアプリが、頭を囲む湾曲した壁のように並びます。マルチモニタのような見え方です。

1 枚のパネルは、1 枚の Canvas(768×640)に、画像・タイトル・説明・ボタンを描いて、CanvasTexture にしたものです。ボタンは、LAUNCH APP と DOCUMENTATION の 2 つで、Canvas に描いた矩形です。

パネルへの当たり判定は、レーザーと平面の交点を求めて、平面の中の座標から、どのボタンかを判定しています。

画像は、crossOrigin = 'anonymous' の Image で読みます。CORS が通らない画像は、頭文字のプレースホルダーのままにしています。

パネルの並べ方は、次のとおりです。

  • 頭の向きを中心に、アーチ状に並べる。1 行は最大 6 枚で、足りなければ段を重ねる
  • 1 枚の幅と高さ、壁の半径は、世界の縮尺と同じ係数で決める

惑星に着いたら、そのカードだけ

最初は、アプリの惑星に着いても、カテゴリの全パネルが並んだままで、該当のパネルが少し大きくなるだけでした。

欲しかった挙動は、全部を出さず、選んだ惑星のカードだけを出すことでした。そこで、アプリの惑星に着いたときは、そのカード 1 枚だけを出して、他は隠して、レーザーの判定からも外しました。カードは、星の方向から少し左に寄せています。右に、記事を置くためです。

段階 3b:記事ボード

アプリの惑星に着くと、カードの右に、記事の本文を読める板が出ます。簡易的なブラウザのようなものです。

VR の中では、Web ページは開けません。そのため、記事の本文を、自分で描く必要があります。

MDX を、ブロックに変換する

ビルド時に、記事の MDX を、行ごとに読んで、ブロックの配列にします。MDX をコンパイルはしません。

{
  "title": "[Astro] #165 サイトマップ …",
  "date": "2026-10-09",
  "blocks": [
    { "t": "h", "l": 2, "x": "はじめに" },
    { "t": "p", "x": "前回(#164)は、…" },
    { "t": "img", "src": "https://…/astro-165-sitemap-3d.webp", "alt": "…" },
    { "t": "video", "src": "https://…/….mp4" }
  ]
}

対応しているブロックは、次のとおりです。

  • 見出し、段落、箇条書き(入れ子・番号付き)、引用
  • コード(先頭 14 行まで。残りは「他 N 行」)
  • 表(最大 14 行 × 5 列)
  • 画像(複数行の <a><img /></a> も読める)
  • LinkCard(同じサイトの記事へのリンクだけ、押せる)
  • 動画(video-frame の中の source)
  • frontmatter の image(アイキャッチ。本文の先頭に置く)

知らない JSX は、(名前) と 1 行だけ出します。強調やリンクの装飾は、VR では出さず、文字だけ残しています。

記事 1 本が、静的な JSON 1 枚

記事ごとに、/sitemap-vr/posts/スラッグ.json と /sitemap-vr/featured/スラッグ.json という静的な JSON を、ビルド時に作ります。VR で開いたときだけ fetch するので、3D ページの重さは変わりません。

Astro の動的ルートで、ファイル名は src/pages/sitemap-vr/[kind]/[slug].json.ts です。記事のファイルを走査して、getStaticPaths でパスを返します。

Canvas に描く

JSON を読んだら、1100×900 の Canvas に、折り返しながら描きます。1 回、全ブロックの高さを計算して、スクロール位置に合わせて、見える範囲だけを描きます。

  • スクロールは、コントローラーのスティックの上下
  • 画像は、読めたものだけ描く。読み込み前や、読めないときは、枠だけを出す
  • 下のバーに、スクロールの割合と、「通常ページで開く」ボタン
  • 動画は、枠と ▶ を出して、トリガーで再生する。再生は、VideoTexture の板を、ボードの前に重ねる

記事の星屑(DUST)に着いたときも、同じボードで読めます。パネルは出さず、星の方向の正面に、ボードだけを出します。

ワープの演出

ノーマンズスカイのようなワープを目指して、出来る範囲で作りました。

  • 星の筋:進行方向に伸びる線を、カメラに付けて、後ろへ流す
  • トンネル:視界の端だけを暗くする
  • 動き:出だしで溜めて、中ほどが最速になるイージング。時間は 0.65 秒

VR は、視野角をアプリから変えられません。視野を広げる演出は、使えません。その代わり、筋とトンネルで、速さを出しています。筋の長さ・速さ・暗さは、VR.fx の数字で調整できます。

星の距離を広げたら、迷子感が出た

縮尺 0.04 のとき、隣の惑星がすぐ見える「ご近所」で、移動が一瞬でした。

縮尺を上げるだけだと、パネルや立ち位置も、一緒に大きくなります。比率が変わらないので、ご近所のままです。そこで、星同士の距離を決める値(WS)と、パネルなどの大きさを決める値(VS)を、別にしました。WS を 0.04 から 0.07 に上げて、星だけを約 1.75 倍、遠ざけました。

すると、別の惑星が、どこにあるのか分からなくなりました。見つからなくて、探すようになります。選んだカテゴリ以外の星は暗く沈んでいるので、レーザーを当てると、初めて見える星もあります。

迷子対策(現在地の表示や、戻るボタン)は、作らないことにしました。宇宙に漂う孤独を、感じさせたいからです。迷ったときは、グリップを押せば、中心に戻れます。

ハマりどころ

1. VR のボタンが出ない(バックアップの名前)

VR の変更前のファイルを、src/pages に 〜.before-vr.astro という名前で置いていました。すると、それが公開ページになって、Quest で開いたのは、その古いバージョンでした。VR ボタンが出ません。

バックアップは、src/pages に .astro として置かない。.bak を付けます。

2. WebXR は、https か localhost でしか動かない

Quest で、PC の LAN の IP アドレス(http://192.168.…)を開いても、VR は始まりません。WebXR は、安全なコンテキスト(https か localhost)でしか動きません。

開発中は、adb reverse tcp:4321 tcp:4321 で、Quest の localhost:4321 を PC につなぎました。

3. ボタンを押すと、フリーズする

パネルのボタンで、別のページへ移動すると、Quest がフリーズすることがありました。VR のセッションを開いたまま、location.href でページを移動していたのが、原因だと考えています。

先にセッションを終わらせて、それから移動するようにしたら、フリーズしなくなりました。

const sess = renderer.xr.getSession();
setTimeout(go, 1500);                 // 終わらなくても、1.5 秒で移動する
try { sess.end().then(go, go); } catch { go(); }

この対策で直ったことは、実機で確認しました。ただし、セッションを開いたまま移動することが、フリーズの原因だと断定する証拠は、取っていません。

4. 「読み込めません」が、全記事で出た

記事ボードを作って、最初に開いたら、全部の記事で「読み込めません」と出ました。

原因は、配置のミスでした。[kind] というフォルダと、[slug].json.ts というファイル名は、角括弧も名前の一部です。ダウンロードのときに、角括弧もフォルダも消えて、slug.json.ts という 1 つのファイルになっていました。

直したあとは、全記事で読めました。失敗したときは、ボードに、原因と取得しようとした URL を出すようにしてあります。デバッグがしやすくなりました。

5. 画面の外のことは、ヘッドレスでは分からない

ヘッドレスのブラウザで、VR をシミュレートして、ボードの表示や、判定を確かめました。ですが、見え方(文字の読みやすさ、星の大きさ)、スティックの向き、酔いやすさは、実機でしか分かりません。

実機で確かめて、直す。この繰り返しでした。

まだ入れていないこと

  • 実機でのフレームレートの計測
  • 動画の再生の確認(CORS を含む)
  • ワープの、細かい調整
  • 時間の回廊:記事を日付順に並べて、手前へ迫ってくるビュー
  • 作品(GALLERY の 14 本)を、3D の星にすること

おわりに

星座の中心に立って、星を渡り歩いて、記事を読む。思っていたより、ずっと、それらしくなりました。

作り込みの順番は、いつもと同じでした。1 つ足しては、見る。気に入らなければ戻せるように、前のファイルを残しておく。VR では、見るときに、Quest をかぶる手間があるので、段階を細かくして、1 つずつ確かめたことが、効きました。

迷子感が出たのは、狙っていませんでした。星を遠ざけただけです。でも、探す時間が、宇宙を旅している感じにつながりました。