はじめに
前回、テクスチャを一切使わず GLSL の 4D ノイズと Domain Warping のみで太陽系を描き切りました。
[Next.js #46] Procedural Solar System ― Three.js × GLSL ─ ノイズだけで描く「太陽系全惑星」と「星雲」
GLSLの4DノイズとDomain Warpingを用いて、木星の大赤斑、土星の環、天王星の垂直リング、海王星の大暗斑をプロシージャルに生成。InstancedMeshによる小惑星帯やOrbitControlsの拡張によるシネマティックなカメラ操作も実装し、ブ …
https://humanxai.info/posts/nextjs-46-procedural-solar-system-complete/しかし、ディスプレイ越しに眺める宇宙と、ヘッドセットを被って目の前に広がる宇宙では、その「実在感」が全く異なります。
今回は、Meta Quest 2 などのデバイスでこのプロシージャル宇宙を探索できるよう、WebXR への対応と、VR 特有の課題解決を行いました。
スクリーンショット:
動画(YouTube):
Procedural Solar System VR | 智も情も届かない、ただ静かな場所へ
「智に働けば角が立つ。情に棹させば流される。」日々の喧騒から離れ、数式とノイズだけで描かれた静寂の宇宙へ。テクスチャを一切使わず、GLSL の 4D ノイズのみで構築したプロシージャルな太陽系を、WebXR(VR)空間に移植しました。地球を俯瞰すること。宇宙飛行士が体験する「オーバービュー・エフェクト」を、自作の...
https://www.youtube.com/shorts/D1Y0K6R0A4M動画(VR/XR):
1. WebXR 対応の鉄則:アニメーションループの移行
通常のモニター越しでは秒間 60フレーム($60\text{Hz}$)が標準的ですが、Meta Quest 2 などの VR ヘッドセットでは 72Hz、90Hz、120Hz といった高いリフレッシュレートでの同期が求められます。
このセクションでは、なぜ従来の requestAnimationFrame では不十分なのか、そしてどのように実装を切り替えるべきかを詳しく解説します。
なぜ setAnimationLoop が必須なのか?
WebXR(VR/AR)モードに入ると、ブラウザは「ディスプレイの描画」ではなく「ヘッドセットの同期信号(Vsync)」に合わせてフレームを更新しなければなりません。
- デバイスとの同期: renderer.setAnimationLoop を使用すると、Three.js が内部的に WebXR セッションの有無を判断し、適切なリフレッシュレートで animate 関数を呼び出してくれます。
- 安定したトラッキング: ヘッドセットの動き(ヘッドトラッキング)と描画をミリ秒単位で同期させるため、ブラウザ側で最適化されたこの専用ループが必要となります。
注意:無限ループの罠
実装時に最も注意すべきは、requestAnimationFrame を消し忘れることです。実際、今回の開発中もこの「二重ループ」によってブラウザがフリーズしかける場面がありました。
- 原因: setAnimationLoop がループを回している中で、さらに関数内で requestAnimationFrame(animate) を呼んでしまうと、1フレーム内に描画処理が倍々ゲームで増殖し、GPU 負荷が限界を超えてしまいます。
- 対策: animate 関数内にある requestAnimationFrame の記述は、WebXR 対応の瞬間に完全に削除しなければなりません。
正しい実装パターン
THREE.Clock を併用し、フレームレートに依存しない時間の流れ(dt)を確保する構成がベストです。
// Clockの初期化
const clock = new THREE.Clock();
function animate() {
// ★ requestAnimationFrame(animate) はここに書かない!
// 前フレームからの経過時間を取得(移動処理に使用)
const dt = clock.getDelta();
const elapsed = clock.getElapsedTime();
// ノイズシェーダーへの時間供給
uniforms.uTime.value = elapsed;
// 各惑星や太陽の更新処理
sun.update(elapsed);
earth.update(dt, elapsed);
// VR移動処理(VRモード中のみ実行)
if (renderer.xr.isPresenting) {
handleVRMovement(dt);
}
controls.update();
renderer.render(scene, camera);
}
// 描画ループの開始
renderer.setAnimationLoop(animate);
この一本化により、PC ブラウザでは通常の 60fps、VR 内ではデバイスの最高性能を引き出したヌルヌルとした描画が、同じコードで実現可能になります。
2. 灼熱の原点からの脱出(カメラリグの構築)
VR モードにおいて、カメラの座標管理は通常のデスクトップ開発とは根本的に異なります。 単に camera.position を書き換えるだけでは解決できない、VR 特有の「物理的な位置」と「仮想的な位置」のジレンマを解決するための実装を解説します。
なぜ「台座(Group)」が必要なのか?
WebXR が有効になると、camera の座標(位置と回転)はヘッドセットのセンサーから送られてくるトラッキングデータによって常に上書きされます。
- 上書きの競合: コード側で camera.position.set() を実行しても、次の瞬間にはヘッドセットの「現実空間での位置」に引き戻されてしまいます。
- 相対座標の解決: プレイヤーを宇宙の特定の場所に配置(ワープ)させるには、カメラ自体を動かすのではなく、カメラを内包する 「親グループ(台座)」 を動かす必要があります。
- リグ構造のメリット: この cameraGroup を動かすことで、ヘッドセット内の自由な動き(しゃがむ、歩く)を維持したまま、プレイヤーを地球のそばや太陽の外側へと安全に運ぶことが可能になります。
太陽の内部からの脱出ロジック
今回のプロジェクトでは、太陽が原点 (0, 0, 0) に配置されているため、何の対策も講じないと VR を開始した瞬間に太陽の炎の中に放り出されてしまいます。
これを回避するために、sessionstart イベントを利用して「ワープ」を処理します。
// 1. カメラリグ(台座)の構築
const cameraGroup = new THREE.Group();
cameraGroup.add(camera); // カメラを台座に乗せる
scene.add(cameraGroup); // 台座を宇宙に配置
// 2. VRセッション開始時の位置リセット
renderer.xr.addEventListener("sessionstart", () => {
// VRに入った瞬間、台座ごと安全な観測地点へワープ
// ここでは太陽から 600 ユニット離れた上空に配置
cameraGroup.position.set(0, 70, 600);
});
没入感を高めるための「初期位置」の重要性
VR において、最初に見える景色はユーザーの体験を大きく左右します。
- スケール感の提示: 太陽を真正面に据え、かつ小惑星帯や惑星の公転が俯瞰できる (0, 70, 600) という座標は、宇宙の広がりを瞬時に理解させるための「特等席」です。
- 酔いの防止: 急激な移動は VR 酔いの原因になりますが、セッション開始時の静止状態でのワープであれば、視覚的なショックを最小限に抑えつつ、ドラマチックな幕開けを演出できます。
このリグ構造を導入したことで、次のステップである「スティックによる自由移動」の実装への道が拓かれました。
3. 宇宙全体のスケールを司る「Universe Group」
VRでの没入感を決定づけるのは「大きさ(スケール感)」です。デスクトップ画面では十分に大きく見えていた惑星も、VR空間ではプレイヤー自身の「体感サイズ」が基準となるため、相対的に小さく感じられてしまうことがあります。
このセクションでは、宇宙全体の整合性を保ちながら圧倒的な巨大感を演出するための「Universe Group」の実装について解説します。
相対関係を保つための「Universe Group」
当初、惑星のMesh(形状)だけを大きくしようとすると、公転半径(距離)とのバランスが崩れ、太陽の中に惑星が飲み込まれてしまうといった事故が発生しました。 そこで、太陽、全惑星、小惑星帯(asteroids)、公転軌道(orbitGroup)、さらには背景の星々(starsGroup)まで、宇宙を構成するすべての要素を一つの universeGroup という親グループに集約しました。
const universeGroup = new THREE.Group();
scene.add(universeGroup);
// 全てのオブジェクトをこのグループの子にする
universeGroup.add(sun.group);
universeGroup.add(orbitGroup);
universeGroup.add(starsGroup);
planetsArray.forEach(p => universeGroup.add(p.group));
このグループを scale.setScalar(v) で一括操作することで、天体同士の距離感や公転軌道のバランスを完璧に維持したまま、宇宙全体の縮尺をダイナミックに変更することが可能になります。
スケールに追従する「環境の動的調整」
宇宙を10倍、20倍と巨大化させると、単なる表示上のスケーリングだけでは解決できない技術的な課題が浮上します。
-
描画限界(Far)の拡張: 宇宙が広がると、遠くの天体がカメラの描画可能範囲(Far Clipping Plane)を越えてしまい、突然視界から消えてしまいます。これを防ぐため、スケーリング値 v に合わせて camera.far を動的に拡張し、updateProjectionMatrix() を実行して即座に反映させる仕組みを導入しました。
-
移動速度(vrSpeed)の同期: 宇宙が巨大化した分、目的地までの物理的な距離も遠くなります。従来の移動速度では、隣の惑星に辿り着くまでに現実の時間で何分もかかってしまいます。そこで、VR内での移動速度 vrSpeed もスケールに比例してブーストさせることで、巨大な宇宙でも軽快な惑星間旅行を維持できるようにしました。
sysFolder.add(params, "globalUniverseScale", 1.0, 100.0).name("宇宙の全スケール").onChange((v) => {
universeGroup.scale.setScalar(v);
// 描画限界をスケールに合わせて拡張(星が消えないように)
camera.far = 4000 * v * 1.5;
camera.updateProjectionMatrix();
// 宇宙の広さに合わせて移動速度もブースト
vrSpeed = 150.0 * v;
});
この一括制御により、目の前を覆い尽くすプロシージャルな惑星の地平線や、圧倒的な質量を感じさせる太陽の迫力など、まさに「自分の手で作り上げた宇宙に放り込まれる」というVRならではの感動を生み出すことができました。
4. VR ロコモーション(移動)の実装
VR 空間という「全方位」が自由な環境において、直感的な移動手段(ロコモーション)の実装は、ユーザーの体験価値を左右する非常に重要な要素です。 本プロジェクトでは、Meta Quest 2 のアナログスティックとボタンを組み合わせ、広大な宇宙をストレスなく、かつ没入感を損なわずに漂える仕組みを構築しました。
アナログスティック:視線基準の水平移動
プレイヤーが「見ている方向」に対して直感的に進めるよう、camera.getWorldDirection を活用したベクトル計算を行っています。
- 方向の取得と水平固定: カメラの向きを取得した後、direction.y = 0 とすることで、スティックを前に倒した際に「意図せず上下に浮き沈みしてしまう」のを防ぎ、水平な移動を実現しています。
- ベクトルの正規化: normalize() を実行することで、斜め移動時でも速度が一定に保たれるようにしています。
- 左右移動の算出: 前方へのベクトルとカメラの上方ベクトルを外積(crossVectors)することで、即座に「右方向」のベクトルを割り出しています。
const direction = new THREE.Vector3();
camera.getWorldDirection(direction); // 見ている方向を取得
direction.y = 0; // 上下移動をキャンセル
direction.normalize();
// 前後移動
cameraGroup.position.addScaledVector(direction, -stickZ * vrSpeed * dt);
// 左右移動(外積で右方向を算出)
const sideDir = new THREE.Vector3().crossVectors(direction, camera.up);
cameraGroup.position.addScaledVector(sideDir, stickX * vrSpeed * dt);
X / Y ボタン:垂直方向の高度調整
宇宙空間では上下の概念が重要になるため、Meta Quest 2 の左コントローラーにある物理ボタンを高度調整に割り当てました。
- 直感的な配置: 下側にある X ボタン(index 4) で下降、上側にある Y ボタン(index 5) で上昇するように設定しています。
- スムーズな昇降: スティック移動と同様に、フレーム間経過時間(dt)と移動速度(vrSpeed)を乗算することで、フレームレートに依存しない滑らかな昇降を実現しました。
if (buttons[4] && buttons[4].pressed) {
cameraGroup.position.y -= vrSpeed * dt; // Xボタンで下降
}
if (buttons[5] && buttons[5].pressed) {
cameraGroup.position.y += vrSpeed * dt; // Yボタンで上昇
}
宇宙を漂う「浮遊感」の演出
このロコモーション実装のポイントは、カメラそのものではなく cameraGroup(台座)を動かしている点です。
これにより、プレイヤーは現実空間で首を振ったり歩いたりする自由を保ったまま、仮想空間内の「乗り物」に乗って宇宙を旅しているような感覚を得ることができます。 スティックを倒した瞬間、目の前の巨大な土星の環がゆっくりと迫ってくる光景は、数式だけで描かれた世界であることを忘れさせるほどのインパクトを与えてくれます。
5. 視覚的ノイズの除去:透明度描画の修正
VR 空間において、透明なオブジェクトの重なり(描画順序)の乱れは、没入感を著しく削ぐ要因となります。 特に惑星の「環」のように、四角い PlaneGeometry の上にシェーダーで円状の模様を描画する場合、透明なはずの領域が背後のオブジェクトを遮断してしまい、不自然な「黒い板」として見えてしまう問題が発生しました。
このセクションでは、グラフィックス理論における深度バッファの仕組みと、それを回避するための 2 つのテクニックを解説します。
1. discard によるピクセルの物理的破棄
通常、フラグメントシェーダーで gl_FragColor のアルファ値を $0$ に設定しても、そのピクセルは「描画された」とみなされ、深度バッファ(奥行き情報)が書き換えられてしまいます。その結果、後続の描画処理(背後の星雲など)が「手前に何かある」と判断して描画をスキップしてしまい、黒い欠けが発生します。
これを防ぐには、透明なピクセルに対して物理的に描画処理を中断させる discard 命令が有効です。
// earth.js のフラグメントシェーダー
void main() {
float d = length(vUv * 2.0 - 1.0);
float m = smoothstep(0.48, 0.47, d) * smoothstep(0.42, 0.43, d);
// 透明度が閾値以下のピクセルを、バッファに書き込まずに破棄する
if (m < 0.01) discard;
gl_FragColor = vec4(vec3(0.8, 0.8, 0.9), m * 0.4);
}
2. depthWrite: false による遮蔽の無効化
discard だけで解決しない、あるいは半透明な境界が重なる場合には、マテリアル設定の depthWrite プロパティを false に設定します。
- 深度テスト(depthTest): 自分より手前にオブジェクトがあるかを確認する(これは通常 true)。
- 深度書き込み(depthWrite): 自分の奥行き情報をバッファに書き込む(これを false にする)。
この設定により、「自分は描画されるが、後続のオブジェクトの邪魔はしない」という振る舞いが可能になります。これにより、惑星の環越しに広大な星雲(Nebula)が美しく透けて見えるようになります。
this.ring = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.ShaderMaterial({
// ... shaders ...
transparent: true,
side: THREE.DoubleSide,
depthWrite: false, // ★背後の描画を妨げない
}),
);
完璧な「宇宙の重なり」
これらの修正により、以前は角度によってチラついていた「透明な境界線」が消え去りました。 巨大化した惑星に近づき、その向こう側に広がる星雲や星々を眺めたとき、視界を遮るものはもう何もありません。
数式だけで構築された太陽系が、VR という自由な視点を得たことで、ようやく一つの「完結した宇宙」として成立した瞬間でした。
💬 コメント