プロジェクトリポジトリ

目的

Unity の XR Interaction Toolkit(XRIT)は、VR 向けの移動(Move)・ジャンプ(Jump)・回転(Turn)をすぐに使える LocomotionProvider として提供している。

このプロジェクトでは、物理ベースのハンドトラッキング(物理の手) を実現するために XR Origin に Rigidbody を付けた。すると標準の Locomotion システムが動かなくなった。

原因を一言で言えば:

「XRIT 標準の Locomotion は CharacterController(または Transform の直接操作)前提で設計されており、Rigidbody と根本的に相性が悪い」

この記事では、標準システムとの相性は何が問題で、どう変更したかを、コードを対比しながら解説する。


第1章:XRIT の Locomotion アーキテクチャを理解する

標準システムの全体像

XRIT の Locomotion は以下の 3 層で構成されている。

┌──────────────────────────────────────────────┐
│  LocomotionProvider(移動・ジャンプ・回転)   │  ← 各プロバイダが処理を登録
│   ContinuousMoveProvider                     │
│   JumpProvider                               │
│   SnapTurnProvider  etc.                     │
└────────────────┬─────────────────────────────┘
                 │ TryQueueTransformation()
                 ▼
┌──────────────────────────────────────────────┐
│  LocomotionMediator                           │  ← 優先度管理・排他制御
│  (どの Provider が今動いていいかを判断)      │
└────────────────┬─────────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────────┐
│  XRBodyTransformer                            │  ← 実際の Transform 操作
│  transformation.Apply()                       │
│  → CharacterController.Move() or             │
│     transform.position += motion             │
└──────────────────────────────────────────────┘

各 Provider は「こう動かしたい」という Transformation(変換情報) を登録するだけで、実際に XR Origin を動かすのは XRBodyTransformer の役割だ。

Rigidbody との非互換な前提

標準システムが最終的に行う操作は:

// XROriginMovement.Apply() の内部(XRIT パッケージ内)
characterController.Move(motion);
// または
origin.transform.position += motion;

transform.position の直接書き換えは、Rigidbody が存在するオブジェクトでは避けた方がよい。

Unity の物理エンジン(PhysX)は、FixedUpdate のタイミングで Rigidbody の位置を管理している。Updatetransform.position を直接書き換えると、物理エンジンが「オブジェクトが突然ワープした」と判断し、衝突判定の無視・速度の乱れ・物理的に不自然な挙動 が起きる。

問題内容
タイミング不整合標準は Update() ベース。Rigidbody は FixedUpdate() で管理される
衝突のバイパスtransform.position += motion は物理エンジンの衝突判定を無視する
重力の二重管理GravityProvider の重力と Rigidbody の useGravity が干渉する

解決策:LocomotionMediator を使わない設計を選ぶ

このプロジェクトでは、Locomotion プロバイダをすべて 独立した MonoBehaviour として作り直した。XRIT の LocomotionProvider を継承せず、LocomotionMediator も使わない方針をとった。

【移植後のアーキテクチャ】

RigidbodyMoveProvider (MonoBehaviour)
RigidbodyJumpProvider (MonoBehaviour)                      Rigidbody を直接操作
RigidbodyTurnProviderBase (abstract MonoBehaviour)
  ├─ RigidbodyContinuousTurnProvider          共通の入力読み取り・TurnRig() を基底クラスに集約
  └─ RigidbodySnapTurnProvider

シンプルだが、LocomotionMediator が担っていた「優先度管理・排他制御・状態イベント(locomotionStarted/Ended)」は失われる。このプロジェクトでは PhysicsHand シーンに特化しており、それらが不要なためこの方針が成立している。汎用的な VR アプリでは LocomotionMediator の恩恵が必要になる場面もあるため、プロジェクトの要件に応じて判断することを勧める。


第2章:Move(移動)の移植

XRIT 標準の移動処理

ContinuousMoveProviderUpdate() の中で以下のフローを実行する。

// ContinuousMoveProvider.Update()(XRIT)
protected void Update()
{
    var input = ReadInput();                      // 左右入力を合算
    var motion = ComputeDesiredMove(input);       // 移動量(ワールド座標)を計算
    MoveRig(motion);                              // XRBodyTransformer に登録
}
 
// MoveRig() の末尾
transformation.motion = motion;
TryQueueTransformation(transformation);
// → XRBodyTransformer が transform.position += motion を実行

ComputeDesiredMove が返すのは 1フレーム分の移動量(position delta) だ。速度に Time.deltaTime を掛けた値になっている。

var speedFactor = m_MoveSpeed * deltaTime * originTransform.localScale.x;
// ...
var translationInRigSpace = forwardRotation * m_InAirVelocity * speedFactor;

Rigidbody ベースへの変更

Rigidbody で移動を制御するには、位置を直接変えるのではなく「速度(velocity)」を設定する のが正しいアプローチだ。

// RigidbodyMoveProvider.FixedUpdate()
void FixedUpdate()
{
    var v = m_Rigidbody.linearVelocity;
    v.x = m_DesiredHorizontalVelocity.x;  // 水平方向だけ上書き
    v.z = m_DesiredHorizontalVelocity.z;
    // v.y は触らない → 重力・ジャンプの Y 速度を保持
    m_Rigidbody.linearVelocity = v;
}

position delta vs velocity の対比

XRIT 標準Rigidbody 版
操作対象transform.position (位置の差分を加算)rb.linearVelocity (速度を設定)
適用タイミングUpdate()FixedUpdate()
重力の扱いGravityProvidermotion.y を計算して加算rb.useGravity = true で物理エンジンに委託

なぜ Update と FixedUpdate に分けるのか?

Update() は毎フレーム呼ばれるが、フレームレートが変動すると呼び出し間隔が変わる。一方 FixedUpdate() は一定間隔(デフォルト 0.02秒ごと)で呼ばれ、物理エンジンと同期している。

void Update()
{
    // 入力の読み取りはフレームごとでOK(取りこぼしを防ぐため Update で)
    var input = ReadInput();
    m_DesiredHorizontalVelocity = ComputeDesiredMove(input);  // キャッシュ
}
 
void FixedUpdate()
{
    // Rigidbody への書き込みは FixedUpdate で(物理エンジンと同期)
    var v = m_Rigidbody.linearVelocity;
    v.x = m_DesiredHorizontalVelocity.x;
    v.z = m_DesiredHorizontalVelocity.z;
    m_Rigidbody.linearVelocity = v;
}

m_DesiredHorizontalVelocityUpdateFixedUpdate の間の バッファ(橋渡し変数) として機能している。

ComputeDesiredMove の変更点

方向計算のロジック(DynamicMoveProvider と同じ「左右入力ブレンド」アルゴリズム)は流用できる。変わるのは 返す値の意味 だ。

// XRIT:1フレームの移動量(= 速度 × deltaTime)を返す
var speedFactor = m_MoveSpeed * Time.deltaTime;
return originTransform.TransformDirection(forwardRotation * inputMove * speedFactor);
 
// Rigidbody 版:速度(m/s)を返す(deltaTime を掛けない)
return originTransform.TransformDirection(forwardRotation * inputMove * m_MoveSpeed);

XRIT 版は「毎フレーム位置を動かす量」、Rigidbody 版は「秒速 N メートルの速度ベクトル」を返している。FixedUpdatelinearVelocity に代入するため、deltaTime は不要になる。


第3章:Jump(ジャンプ)の移植

XRIT 標準の Jump 処理

JumpProvider「毎フレーム上方向に位置を加算していく」 モデルで実装されている。

// JumpProvider.UpdateJump()(XRIT)
void UpdateJump()
{
    ProcessJumpForce(Time.deltaTime);
    // jumpForce は時間とともに減衰する(山なりの軌跡)
    m_JumpVector.y = m_CurrentJumpForceThisFrame * Time.deltaTime;
 
    transformation.motion = m_JumpVector;
    TryQueueTransformation(transformation);  // → transform.position += motion
}
 
// 力の減衰計算
float CalculateJumpForceForFrame(float normalizedJumpTime)
{
    // 正規化時間 0→1 で力が (1 - t) の形で減衰
    return (1 - normalizedJumpTime) * m_JumpHeight * approximateForceToMeters;
}

重力は GravityProvider が別途管理し、JumpProvider はジャンプの上昇分だけを担当する。接地判定も GravityProvider.isGrounded に依存しており、JumpProvider 単独では動かない

// Awake() で GravityProvider を検索、なければ自身を無効化
m_HasGravityProvider = ComponentLocatorUtility<GravityProvider>.TryFindComponent(out m_GravityProvider);
if (!m_HasGravityProvider)
{
    Debug.LogError("Could not find Gravity Provider...");
    enabled = false;
}

Rigidbody ベースへの変更

Rigidbody があれば、重力は useGravity = true で物理エンジンが自動処理する。ジャンプは AddForce(Impulse) で瞬間的な上向き速度を与えるだけでいい。

// RigidbodyJumpProvider.FixedUpdate()
void FixedUpdate()
{
    if (m_JumpRequested && IsGrounded())
        m_Rigidbody.AddForce(
            m_XROrigin.Origin.transform.up * m_JumpForce,
            ForceMode.Impulse  // 瞬間的な衝撃力
        );
 
    m_JumpRequested = false;
}

ForceMode の違い

AddForce には複数のモードがある:

ForceMode効果使いどき
Force質量を考慮した継続的な力エンジン・風など
Acceleration質量無視の継続的な加速重力など
Impulse質量を考慮した瞬間的な力ジャンプ・爆発など
VelocityChange質量無視の瞬間的な速度変化速度を直接設定したいとき

Impulse は「力積(force × time)」に相当し、mass × velocity の単位を持つ。つまり Rigidbody の質量が大きいほど、同じ jumpForce では飛ぶ高さが低くなる ことに注意が必要だ。

接地判定の変更

XRIT の GravityProvider頭部(カメラ)の位置から下向きに SphereCast で接地を判定する。

// GravityProvider.CheckGrounded()(XRIT)
m_CastOrigin = GetBodyHeadPosition();           // 頭の位置から
m_CastDirection = -GetCurrentUp();              // 下方向へ
m_CastDistance = CameraInOriginSpaceHeight;     // 身長分の距離
Physics.SphereCast(origin, radius, dir, hits, distance, layerMask);

Rigidbody 版では、カプセルコライダーの底部から CheckSphere を使う。物理エンジンの衝突判定に使われているコライダーと形状が一致するため、より正確だ。

// RigidbodyJumpProvider.IsGrounded()
bool IsGrounded()
{
    var center = origin.TransformPoint(m_CapsuleCollider.center);
    // カプセルの底面球の中心を計算
    var bottomCenter = center - origin.up * (m_CapsuleCollider.height / 2f - m_CapsuleCollider.radius);
 
    return Physics.CheckSphere(
        bottomCenter,
        m_CapsuleCollider.radius + m_GroundCheckDistance,  // 少しだけ大きめに
        m_GroundLayerMask,
        QueryTriggerInteraction.Ignore
    );
}
【XRIT の接地判定】           【Rigidbody 版の接地判定】

  ★ 頭の位置                     ○ カプセルの上端
  |                              |
  | SphereCast(長い)           |
  |                              ○ カプセルの底端
  ↓                           (   ) ← CheckSphere(小さい球)
=======床=======            =======床=======

ボタン入力の違い

XRIT の JumpProviderXRInputButtonReader(ボタン専用リーダー)を使い、ReadIsPerformed() で「押されているか」を判定する。

// JumpProvider(XRIT):「押されている間」を検出
if (!m_HasJumped && m_JumpInput.ReadIsPerformed())
    Jump();
// m_HasJumped フラグでボタンを離すまで再ジャンプを防ぐ

Rigidbody 版も同じく XRInputButtonReader を使い、ReadWasPerformedThisFrame() で「押した瞬間」を拾う。Input System がフレーム単位のエッジ検出を内部で行うため、手動のフラグ管理が不要になった。

// RigidbodyJumpProvider(Rigidbody 版):「押した瞬間」を検出
void Update()
{
    // ReadWasPerformedThisFrame() はこのフレームで初めて押された時だけ true
    if (m_JumpInput.ReadWasPerformedThisFrame())
        m_JumpRequested = true;
}

どちらも「ボタンを押し続けても1回しかジャンプしない」という動作だが、Rigidbody 版は Input System に判定を委ねることで m_WasJumpPressed のようなフラグ変数が不要になっている。


第4章:Turn(回転)の移植

XRIT 標準の回転処理

ContinuousTurnProviderSnapTurnProvider ともに、最終的に XRBodyYawRotation という Transformation を XRBodyTransformer に渡し、Y 軸回転を Transform に直接適用 している。

// ContinuousTurnProvider.TurnRig()(XRIT)
transformation.angleDelta = turnAmount;
TryQueueTransformation(transformation);
// → XRBodyYawRotation.Apply() が origin.transform.rotation を変更

XRBodyYawRotation.Apply() の内部では、カメラ(頭)の位置を基準にした Y 軸回転が計算される。つまり 「頭の位置の真下を軸として回転」するピボット処理 が含まれている。

Rigidbody ベースへの変更:TurnRig の実装

Rigidbody に対して transform.rotation を直接変えると、物理エンジンが「オブジェクトが瞬間的に向きを変えた」と誤認する。正しくは MoveRotation() を使う。

// RigidbodyContinuousTurnProvider.TurnRig()(Rigidbody 版)
void TurnRig(float turnAmount)
{
    var origin = m_XROrigin.Origin.transform;
    var pivot = GetBodyGroundWorldPosition();  // ピボット点を自前で計算
    var rot = Quaternion.AngleAxis(turnAmount, origin.up);
 
    m_Rigidbody.MovePosition(pivot + rot * (m_Rigidbody.position - pivot));
    m_Rigidbody.MoveRotation(rot * m_Rigidbody.rotation);
}

MovePosition / MoveRotation vs transform への直接操作

transform.position = / transform.rotation =rb.MovePosition() / rb.MoveRotation()
呼び出しタイミングいつでも可FixedUpdate() 内で使うべき
物理エンジンとの整合無視する(ワープ扱い)協調する(衝突判定を維持)
中間の衝突判定なしあり
速度のリセットなし(速度は変わらない)なし

MovePosition は「次の FixedUpdate までの間に、衝突を検知しながら目的地まで動かす」という動作をする。Rigidbody を持つオブジェクトの移動・回転には、このアプローチが適している。

ピボット計算を自前で実装する

XRIT の XRBodyYawRotation が内部でやっていたピボット計算を、自前で実装する必要があった。

目標:「カメラ(頭)の位置の真下(床面)」を軸として回転させる

protected Vector3 GetBodyGroundWorldPosition()
{
    // CameraInOriginSpacePos: カメラの XR Origin ローカル座標を返す API
    var bodyLocalPos = m_XROrigin.CameraInOriginSpacePos;
    bodyLocalPos.y = 0f;  // ローカル Y を 0 にして床面上の点を得る
    return m_XROrigin.Origin.transform.TransformPoint(bodyLocalPos);
}

m_XROrigin.CameraInOriginSpacePos はカメラの XR Origin ローカル座標を返す。ローカル Y を 0 にすることで「カメラ真下の Origin ローカル床面の点」を得て、TransformPoint でワールド座標に変換する。

CameraInOriginSpacePos    ←── カメラの Origin ローカル座標 (x, y, z)
↓ y = 0 にする
(x, 0, z)                 ←── 高さ成分をゼロにした水平位置
↓ TransformPoint でワールド座標へ
GetBodyGroundWorldPosition()  ←── 床面上のカメラ直下の点(ピボット点)

図にすると:

  ★ カメラ(頭)
  |  ↑ CameraInOriginSpacePos.y(ローカル Y)をゼロにして除去
  ●  ← GetBodyGroundWorldPosition() = ピボット点
  |
  □ origin.position
======= 床

Snap と Continuous の入力処理の違い

SnapTurnProvider(XRIT)CardinalUtility.GetNearestCardinal() で入力方向を 4 方向に分類する。

// SnapTurnProvider.GetTurnAmount()(XRIT)
var cardinal = CardinalUtility.GetNearestCardinal(input);
switch (cardinal)
{
    case Cardinal.East: return m_TurnAmount;    // 右に45°
    case Cardinal.West: return -m_TurnAmount;   // 左に45°
    case Cardinal.South: return 180f;            // 後ろに180°
}

RigidbodySnapTurnProvider:Cardinal 分類を使わず、閾値(0.5)比較と立ち上がりエッジ検出で実装した。

// RigidbodySnapTurnProvider.GetTurnAmount()
float GetTurnAmount(Vector2 input)
{
    if (m_EnableTurnAround && input.y <= -k_Threshold)  // 後ろ倒しを優先
        return 180f;
    if (m_EnableTurnLeftRight && Mathf.Abs(input.x) >= k_Threshold)
        return Mathf.Sign(input.x) * m_TurnAmount;
    return 0f;
}

XRIT 版と同様、Rigidbody 版も左右の入力を 合算(left + right) する。共通処理は RigidbodyTurnProviderBase.ReadInput() に集約されている。

// ReadInput()(RigidbodyTurnProviderBase)- XRIT と同じく合算
protected Vector2 ReadInput()
{
    return m_LeftHandTurnInput.ReadValue() + m_RightHandTurnInput.ReadValue();
}

デバウンス(連続スナップ防止)の実装も異なる:

// XRIT:Time.time を記録して経過時間を比較
if (m_TimeStarted > 0f && (m_TimeStarted + m_DebounceTime < Time.time))
    m_TimeStarted = 0f;  // デバウンス解除
 
// Rigidbody 版:カウントダウン方式
m_DebounceTimer -= Time.deltaTime;
if (isActive && !m_WasActive && m_DebounceTimer <= 0f)
{
    m_CurrentTurnAmount = snapAngle;
    m_DebounceTimer = m_DebounceTime;  // タイマーリセット
}

ContinuousTurnProvider の入力スケーリング:XRIT 版は input.magnitude でアナログ入力の大きさを回転速度に反映する。

// ContinuousTurnProvider.GetTurnAmount()(XRIT)
return input.magnitude * (Mathf.Sign(input.x) * m_TurnSpeed * Time.deltaTime);
// スティックを半分倒すと半分のスピードで回転

Rigidbody 版も XRIT と同様に input.magnitude を使う(Time.fixedDeltaTime を使う点のみ異なる)。

// RigidbodyContinuousTurnProvider.GetTurnAmount()
return input.magnitude * (Mathf.Sign(input.x) * m_TurnSpeed * Time.fixedDeltaTime);

第5章:移植のまとめとパターン

移植で現れた共通パターン

この移植全体を通じて、3つの変更パターンがあった。

パターン A:Transform 操作 → Rigidbody API に置き換える

変更前(XRIT)変更後(Rigidbody 版)
transform.position += motionrb.linearVelocity = velocity
transform.rotation = newRotrb.MovePosition() + rb.MoveRotation()
CharacterController.Move()rb.AddForce(, Impulse)

パターン B:Update タイミング → FixedUpdate タイミングに分離する

どのクラスも「Update で入力を読んでキャッシュ → FixedUpdate で Rigidbody に適用」という 2 段構成になっている。

// 共通パターン
void Update()    { m_CachedXxx = ReadInput() + 計算; }
void FixedUpdate() { Apply m_CachedXxx to Rigidbody; }

パターン C:依存コンポーネントの削除

XRIT 版Rigidbody 版
GravityProvider(必須)rb.useGravity = true(物理エンジン)
LocomotionMediator(必須)不要
CharacterController(必須)CapsuleCollider + Rigidbody

失ったもの、得たもの

失ったもの(XRIT の機能で未実装)

機能元のクラス
コヨーテタイム(崖端ジャンプ猶予)JumpProvider
可変高さジャンプ(長押しで高く)JumpProvider
空中ジャンプ(多段ジャンプ)JumpProvider
VR 酔い対策の回転 delaySnapTurnProvider
他 Provider との排他制御LocomotionMediator
空中での入力スムージングContinuousMoveProvider.inAirControlModifier

得たもの(Rigidbody ならではの利点)

  • 物理ハンドとの自然な押し合い:プレイヤーの体が物理オブジェクトになるため、物理ハンドで壁を押しながら移動できる
  • 摩擦・バウンドのシミュレーション:PhysicsMaterial による摩擦設定が機能する
  • Rigidbody 同士の衝突:他の物理オブジェクトと正しく干渉する
  • コードのシンプルさ:XRIT の LocomotionMediator 経由の複雑な状態管理が不要

おわりに

XRIT の標準 Locomotion は「いつでも使えるレベルの汎用性」を追求して設計されており、テレポートや Vignette といった周辺機能との連携が容易になっている。一方、このプロジェクトでは「PhysicsHand シーン専用」に特化することで、コードをシンプルに保てた。

どちらが優れているかではなく、プロジェクトの要件次第でトレードオフが変わる。ソフトウェア設計における 「汎用性 vs シンプルさ」のトレードオフ がよく現れている例だ。汎用ライブラリを使わずに自前で実装するときは、「何を省略するか」を意識的に決めることが重要だ。


参考ファイル

ファイル役割
Assets/SimplePhysicsLocomotion/Scripts/RigidbodyMoveProvider.cs移動(実装)
Assets/SimplePhysicsLocomotion/Scripts/RigidbodyJumpProvider.csジャンプ(実装)
Assets/SimplePhysicsLocomotion/Scripts/RigidbodySnapTurnProvider.csスナップ回転(実装)
Assets/SimplePhysicsLocomotion/Scripts/RigidbodyContinuousTurnProvider.cs連続回転(実装)
Assets/SimplePhysicsLocomotion/Scripts/RigidbodyTurnProviderBase.cs回転基底クラス(実装)