はじめに
昨日の記事の続きで、VRで起動する事が出来るようになったので、本日は左右コントローラのレバーで移動と視点の回転、更にキャラクターが追尾する実装まで出来たのでそのメモです。
スキル不足で、まだ実装が不十分なところはありますが、勉強しつつ修正していく予定です。
[JavaScript] Three.jsでWebXRを実装(Meta Quest 2)
Three.jsを使ってVR環境を作成し、Meta Quest 2でのVRゲームを実装する方法をステップバイステップで解説。VRモードのカメラ制御やコントローラー入力処理の実装を学びましょう。
/posts/javascript-three-js-vr-oculus-quest-2-implementation/以下は、Meta Quest2 でキャプチャした動画。
初期化(playerRig / camera / model)
WebXR(VR)では、カメラ(HMD)は毎フレームXR側が姿勢・位置を更新します。
「カメラをワールド直下で動かす」より、プレイヤーの基準点(体)=playerRigを作って、移動や回転は全部そこに集約するのが一番安定します。
この初期化はそのための土台になります。
1 playerRig を作ってシーンに追加
config.playerRig = new THREE.Object3D();
config.scene.add(config.playerRig);
- Object3D は “空の箱(親ノード)” だと思っていい
- ここを 「プレイヤーの体の中心(移動・回転の基準)」 にする
- 以後、移動= playerRig.position、回転= playerRig.rotation.y を触るのが基本
2 カメラを playerRig の子にする(重要)
config.playerRig.add(config.camera);
config.camera.position.set(0, 0, 0);
- VR中の config.camera は HMD(頭)に相当
- WebXRはカメラの姿勢(向き)や位置を更新するので、カメラ単体を動かす設計にすると破綻しがち
- カメラを playerRig の子にすると、こういう関係になる:
playerRig(体)
└ camera(頭)
- camera.position.set(0,0,0) は「体の基準点にカメラをぶら下げる」ための初期値 (VRの実際の頭位置はXRが更新するので、ここに変なオフセットを入れると混乱する)
3 キャラクターモデル(player.box)も playerRig の子にする
config.playerRig.add(config.player.box);
config.player.box.position.set(0, 0, 0);
- キャラ(見た目)も playerRig の子にして、こうする:
playerRig(体)
├ camera(頭)
└ player.box(キャラ見た目)
- こうしておくと、playerRig を移動/回転した時に カメラもキャラも一緒に動く
- player.box.position.set(0,0,0) は「体の基準点にキャラを置く」という意味 ※モデルの原点が足元じゃない場合は、ここで y や z を調整する(モデル都合の調整)
この構造にするメリット(実装がシンプルになる)
- 移動:playerRig.position を動かす
- 旋回:playerRig.rotation.y を回す
- キャラ追従:毎フレーム position をコピーしなくていい(親子関係で勝手に追従)
よくある注意点(この初期化で避けたい事故)
- カメラをワールド直下に置いたまま、キャラだけを追従させる → カメラ(頭)の動きと体の動きが分離してズレやすい
- camera.position に固定の前後オフセットを入れる → “頭が体からずれた状態”を人工的に作ってしまい、挙動がややこしくなる
左コントローラ:移動
この関数は 「左スティックの入力に応じて、playerRig(体)を前後左右に移動する」 という最も基本的な移動処理を実装している。
WebXR ではカメラ(頭)は XR が動かすので、 移動するのは playerRig(体)だけ。 この関数はそれに完全に合った構造になっている。
function handLeftMove(inputSource, dt) {
const gp = inputSource.gamepad;
const lx = gp.axes[2] || 0;
const ly = gp.axes[3] || 0;
const speed = 2.0;
const forward = new THREE.Vector3(0, 0, -1);
forward.applyEuler(config.playerRig.rotation);
forward.y = 0;
forward.normalize();
const strafe = new THREE.Vector3(-forward.z, 0, forward.x);
config.playerRig.position.addScaledVector(forward, -ly * speed * dt);
config.playerRig.position.addScaledVector(strafe, lx * speed * dt);
}
1 左スティックの入力を読み取る
const lx = gp.axes[2] || 0;
const ly = gp.axes[3] || 0;
- lx → 左右入力
- ly → 前後入力
- VRコントローラでは axes の番号がコントローラによって違うが、あなたの環境では 2 と 3 が正しい
スティックを倒すと -1 ~ 1 の値が入る。
2 移動速度を決める
const speed = 2.0;
1秒あたりの移動距離。 これは自由に調整していい(走りたいならこの値を上げる)。
3 「キャラが向いている方向=前方向」を作る
const forward = new THREE.Vector3(0, 0, -1);
forward.applyEuler(config.playerRig.rotation);
forward.y = 0;
forward.normalize();
ここが最重要部分。
new THREE.Vector3(0,0,-1)
Three.js では Z軸の -1 が前方向。 この “基準の前方向” を作り、
applyEuler(config.playerRig.rotation)
プレイヤーの体(playerRig)が向いている方向に回転させる。
→ 前方向が「キャラの正面」になる。
forward.y = 0
上下移動を防ぐため、水平成分だけにする。
normalize()
長さを1にして、計算を安定させる。
4 横移動(ストレイフ方向)を計算
const strafe = new THREE.Vector3(-forward.z, 0, forward.x);
- 前方向が (fx, 0, fz) なら
- 横方向は ( -fz, 0, fx ) という 90°回転したベクトルになる。
→ W/A/S/D の A(左)と D(右)に相当。
5 playerRig(体)を動かす
config.playerRig.position.addScaledVector(forward, -ly * speed * dt);
config.playerRig.position.addScaledVector(strafe, lx * speed * dt);
● 前後移動
- ly が -1 → 前進
- ly が +1 → 後退 -ly としてるのは、VR ゲームパッドの軸が“前倒し=負値”だから。
● 左右移動
- lx が -1 → 左
- lx が +1 → 右
● dt(deltaTime)でフレーム安定化
フレームレートが変わっても移動速度が一定になる。
- camera(頭)を動かない
- playerRig(体)だけ動く
- forward を playerRig から計算するので、回転と移動が自然につながる
- キャラ(player.box)は playerRig の子なので、追従コード不要
VR の“歩行操作”として最も標準的な実装になっている。
右コントローラ:回転
右コントローラでは、右スティックの左右入力を使って playerRig(体)の向きを回転させる。
WebXR では「頭(camera)」は XR が動かし続けるため、 視点とは別に “体の向きを回す”ための回転軸 を持つ必要がある。 それが playerRig.rotation.y で、ここを回すことでキャラの真正面方向が決まる。
function handRightDirection(inputSource, dt) {
const gp = inputSource.gamepad;
const rx = gp.axes[2] || 0;
const rotateSpeed = 2.5;
config.playerRig.rotation.y -= rx * rotateSpeed * dt;
config.player.box.rotation.y = config.playerRig.rotation.y;
}
1 右スティックの入力を取得
const rx = gp.axes[2] || 0;
- rx → 右スティックの左右入力値(-1 ~ 1)
あなたのデバイスでは axes[2] が左右入力の軸だったため、この番号にしてある。
2 回転速度を決める
const rotateSpeed = 2.5;
- 数値が大きい → スティックを倒したときに速く回転
- 小さい → なめらかに回る
VR の「スムーズターン」と同じ考え方。
3 playerRig(体の向き)を回す
config.playerRig.rotation.y -= rx * rotateSpeed * dt;
● playerRig.rotation.y
キャラクターの“体の向き”に相当する。
- rx が右なら正方向
- rx が左なら逆方向
- dt(前フレームとの時間差)をかけることでフレームレートに依存しない回転にする
ここを回すことで、移動処理の forward ベクトルが自動的に変化し、 左スティック操作と整合性が取れる。
4 キャラクターモデルも同じ向きに合わせる
config.player.box.rotation.y = config.playerRig.rotation.y;
見た目のモデル(player.box)も体と同じ方向に向ける。
これをしないと、
- 体は動いているのにキャラモデルだけ別方向を見る
- 移動とアニメーションの向きがずれる
こういう違和感が出るため、毎フレーム同期させている。
まとめ
右スティックを左右に倒すたびに
- rx に -1~1 の値が入る
- playerRig.rotation.y が変わる
- 左スティックの移動ベクトルにも反映される
- キャラは“向きを変えながら移動”できるようになる
つまり、この関数が VRキャラ操作に必要な「振り向き」「旋回」機能を担っている。
WebXR Animation Loop
このループは WebXR での“心臓部”で、 毎フレーム、入力を読み取り → キャラを動かし → 描画する という流れをすべてここで行っている。
WebXR では requestAnimationFrame ではなく renderer.setAnimationLoop() を使うのが正しい。
config.renderer.setAnimationLoop((timestamp, frame) => {
if (!frame) return;
const session = frame.session;
const refSpace = config.renderer.xr.getReferenceSpace();
// deltaTime 計算
if (!config.lastTime) config.lastTime = timestamp;
const dt = (timestamp - config.lastTime) / 1000;
config.lastTime = timestamp;
// コントローラ入力
for (const inputSource of session.inputSources) {
if (!inputSource.gamepad) continue;
if (inputSource.handedness === 'left') {
handLeftMove(inputSource, dt);
}
if (inputSource.handedness === 'right') {
handRightDirection(inputSource, dt);
}
}
// 描画
config.renderer.render(config.scene, config.camera);
});
1 frame が無いときは処理しない
if (!frame) return;
VR セッションが開始されていない時や、 WebXR がまだフレーム情報を返していないタイミングでは frame が null になる。
その場合は何もせず終了。
2 WebXR の session と参照空間を取得
const session = frame.session;
const refSpace = config.renderer.xr.getReferenceSpace();
- session … 現在の XR セッション(VR そのもの)
- refSpace … XR の座標基準(local-floor / local など)
移動や入力の取得には、この 2 つが必ず必要になる。
3 deltaTime(前フレームとの時間差)を計算
if (!config.lastTime) config.lastTime = timestamp;
const dt = (timestamp - config.lastTime) / 1000;
config.lastTime = timestamp;
- timestamp はフレームの時刻
- dt は前フレームとの差(秒)
dt を使うことで、60fps でも 90fps でも 移動速度が安定する。
4 すべての VR コントローラを走査する
for (const inputSource of session.inputSources) {
if (!inputSource.gamepad) continue;
WebXR では
- 左手のコントローラ
- 右手のコントローラ
- ハンドトラッキング
などが inputSources に入る。
inputSource.gamepad が無いものはスティック操作ができないのでスキップ。
5 左右のコントローラを分岐
if (inputSource.handedness === 'left') {
handLeftMove(inputSource, dt);
}
if (inputSource.handedness === 'right') {
handRightDirection(inputSource, dt);
}
- 左手 → 左スティック移動処理
- 右手 → 右スティック回転処理
handLeftMove と handRightDirection を 「毎フレーム呼ぶ」のがこのループの役割。
6 最後に描画
config.renderer.render(config.scene, config.camera);
移動・回転が終わったあとの位置/向きをもとに VR 空間を描画する。
WebXRの場合、 この描画は左右の目(ステレオビュー)に自動分割される。
このループで大事なポイントは 3つ
毎フレームごとにコントローラ入力を読む
WebXRでの入力は “イベント” ではなく 毎フレームポーリング する必要がある。
移動と回転はここで呼び出す
先に処理して後で描画することで ラグが発生しない自然な VR 操作になる。
XR 内部の時間で動く(dt を使う)
物理的なフレーム速度に左右されない。
これで
- playerRig の移動
- playerRig の回転
- カメラ追従
- VR空間描画
が全部整い、WebXRのキャラ移動が完成する。
💬 コメント