プロジェクトリポジトリ
目的
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 の位置を管理している。Update で transform.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 標準の移動処理
ContinuousMoveProvider は Update() の中で以下のフローを実行する。
// 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() |
| 重力の扱い | GravityProvider が motion.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_DesiredHorizontalVelocity は Update と FixedUpdate の間の バッファ(橋渡し変数) として機能している。
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 メートルの速度ベクトル」を返している。FixedUpdate で linearVelocity に代入するため、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 の JumpProvider は XRInputButtonReader(ボタン専用リーダー)を使い、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 標準の回転処理
ContinuousTurnProvider・SnapTurnProvider ともに、最終的に 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 += motion | rb.linearVelocity = velocity |
transform.rotation = newRot | rb.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 酔い対策の回転 delay | SnapTurnProvider |
| 他 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 | 回転基底クラス(実装) |