はじめに

昨日の記事の続きで、VRで起動する事が出来るようになったので、本日は左右コントローラのレバーで移動と視点の回転、更にキャラクターが追尾する実装まで出来たのでそのメモです。

スキル不足で、まだ実装が不十分なところはありますが、勉強しつつ修正していく予定です。

以下は、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)も体と同じ方向に向ける。

これをしないと、

  • 体は動いているのにキャラモデルだけ別方向を見る
  • 移動とアニメーションの向きがずれる

こういう違和感が出るため、毎フレーム同期させている。


まとめ

右スティックを左右に倒すたびに

  1. rx に -1~1 の値が入る
  2. playerRig.rotation.y が変わる
  3. 左スティックの移動ベクトルにも反映される
  4. キャラは“向きを変えながら移動”できるようになる

つまり、この関数が 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のキャラ移動が完成する。