[Astro] #167 サイトマップ #03 — VR で星を渡り歩き、パネルと記事ボードで、記事まで読めるところまで
はじめに
前回までで、3D サイトマップを作って、作り込みました。カテゴリが大きな星、アプリが周りの星、記事が星屑で、クリックでワープします。
[Astro] #165 サイトマップ — 3D の星座と、JavaScript なしの一覧で、サイト全体を見渡せるページを作成 // PROTOCOL.LAIN
ツールと記事が増えすぎて、作者自身も全体を把握できなくなってきたので、サイトマップを作りました。
lain-lab.com
[Astro] #166 サイトマップ 02 — 星に光を足して、霧と粒子と検索を入れ、VR へつなぐところまで作り込み // PROTOCOL.LAIN
前回作った 3D サイトマップを、「丸と線だけ」の状態から作り込みました。
lain-lab.com#166 の最後に、VR 化の方針を書きました。今回は、その続きです。Meta Quest 3 で、星座の中を渡り歩いて、記事を読むところまで動きました。
今回の要点は次の 4 つです。
- 星座の中心に立って、レーザーで星を指して、ワープで渡り歩く
- 着いた先で、アプリをパネルで壁のように並べる
- アプリの惑星に着くと、カードの右に、記事の本文が出る(記事ボード)
- 星の距離を広げたら、迷子感が出た。それは残すことにした
完成画面
動画
カテゴリの惑星へ移動
カテゴリの惑星からアプリ記事へ移動
ブログ記事の星へ移動
操作は次のとおりです。
- ページの「ENTER VR」を押す。星座の中心に立つ
- コントローラーのレーザーで星を指して、トリガーで移動する
- カテゴリの星に着くと、そのカテゴリのアプリが、湾曲したパネルの壁で並ぶ
- アプリの惑星に着くと、そのアプリのカードだけが出て、右に記事の本文が出る。スティックの上下でスクロールする
- グリップで、中心(ホーム)に戻る
段階に分けて、1 つずつ確かめた
一気に作らず、段階に分けました。1 つ作るたびに、Quest で確かめてから次へ進みました。
| 段階 | 内容 |
|---|---|
| 1 | VR で世界が見える。中心から、星を指して移動する |
| 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 つずつ確かめたことが、効きました。
迷子感が出たのは、狙っていませんでした。星を遠ざけただけです。でも、探す時間が、宇宙を旅している感じにつながりました。