はじめに
VR アプリの移動方式として定番のテレポートは酔いにくい反面、「瞬間的に景色が切り替わる」ため空間の連続性が切れて没入感が失われてしまいます。この記事では、XR Interaction Toolkit(以下 XRI)3.x のテレポートを最小限の改造で「シフト移動(ダッシュ移動)」に変える方法を紹介します。
XRI の TeleportationProvider を継承したクラスを 1 つ作り、XR Origin プレハブ上のコンポーネントを差し替えるだけで、行き先の指定・着地判定といった既存の仕組みをそのまま活かせます。
環境
- Unity 6
- XR Interaction Toolkit 3
シフト移動とは
Half-Life: Alyx などで採用されている移動方式です。
- 操作はテレポートと同じ。ポインタで行き先を指定して決定する
- 瞬間的に切り替わる代わりに、目的地まで 0.2〜0.3 秒程度、等速で高速にスライド移動する
テレポートより空間の把握と「移動している実感」が保たれ、スティックで連続的に移動するスムーズロコモーションよりはるかに酔いにくい、という中間的な位置づけの方式です。
酔い対策としての設計方針
実装に入る前に、酔いを誘発しないための方針を先に決めておきました。
| 項目 | 方針 |
|---|---|
| 移動速度 | 等速。イージング禁止(速度変化のベクションが酔いを誘発する) |
| 回転 | 補間しない。移動開始時に一度だけ瞬時に適用する |
| 排他制御 | 移動中は他のロコモーションと次のテレポートを受け付けない |
| 壁抜け | 直線補間なので経路上の壁はすり抜ける。0.25 秒なら許容とした |
| ビネット | XRI 標準の TunnelingVignetteController を使う。自前実装しない |
| 瞬間テレポートとの切替 | Inspector のフラグと public プロパティで切り替えられるようにする |
視界の回転の補間は並進よりはるかに酔いやすいので、回転だけは瞬時に適用するのがポイントです。
実装方針: 何を流用し、何を差し替えるか
-
流用する(変更しない): 行き先の決定まわり一式。
TeleportationArea/TeleportationAnchor、Teleport Interactor のレイキャスト、着地判定、リティクル、入力バインド -
差し替える: 移動の適用のみ。
TeleportationProviderを継承したShiftTeleportationProviderを作り、XR Origin プレハブ上の既存TeleportationProviderをこのクラスに置き換える
XRI 3.x では、ロコモーションプロバイダは XR Origin を直接書き換えません。LocomotionMediator に状態(Idle / Preparing / Moving / Ended)を管理させ、XRBodyTransformer に IXRBodyTransformation をキューで渡して移動を適用します。
シフト移動は「足元位置を始点から目的地へ補間する変換」を毎フレーム積む形で実現します。
事前に確認したこと(XRI 3 のソースを読んで確定した事実)
継承で実装できるかどうかは基底クラスの作りに依存するので、Library/PackageCache/com.unity.xr.interaction.toolkit@<hash>/Runtime/Locomotion/ 以下のソースを読んで以下を確認しました。
TeleportationProvider
-
public virtual bool QueueTeleportRequest(TeleportRequest)は virtual なので override できる -
protected virtual void Update()も virtual -
protected TeleportRequest currentRequest { get; set; }、protected bool validRequest { get; set; }が派生クラスから触れる - 回転用の変換オブジェクト
upTransformation(XROriginUpAlignment)とforwardTransformation(XRCameraForwardXZAlignment)が public で用意されている。回転はこれを流用できる
LocomotionProvider
-
[DefaultExecutionOrder(-210)]が付いている。XRBodyTransformerは -205 なので、プロバイダのUpdateで積んだ変換は同じフレームで適用され、キューは毎フレーム消費される -
TryStartLocomotionImmediately()は同じプロバイダが既に Moving なら false を返す(連打防止に使える) -
TryQueueTransformation(IXRBodyTransformation)は Moving 状態でのみ成功する -
TryEndLocomotion()を呼ぶと Ended になり、翌フレームに Idle へ戻る -
isLocomotionActiveは Preparing または Moving で true。ビネットはこれを見ている
XRBodyGroundPosition.Apply
「足元位置 body.GetBodyGroundWorldPosition() を targetPosition に一致させる」ように origin を動かしています。つまり補間の始点は origin.position ではなく足元位置にしないと、着地位置が瞬間テレポートとずれます。
TunnelingVignetteController
m_LocomotionVignetteProviders に登録したプロバイダの isLocomotionActive を毎フレーム見て、自動で ease in / ease out してくれます。プロバイダ側でビネットを意識する必要はありません。
TeleportationArea のプロバイダ解決
床側で Teleportation Provider が未設定(null)の場合、FindAnyObjectByType<TeleportationProvider>() で解決されます。派生クラスを 1 つだけシーンに置けば、既存の床はそのまま使えます。
実装: ShiftTeleportationProvider
以下が全文です。
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.XR.Interaction.Toolkit.Locomotion;
using UnityEngine.XR.Interaction.Toolkit.Locomotion.Teleportation;
/// <summary>
/// テレポート先まで瞬間移動する代わりに、短時間の等速スライドで移動させるプロバイダ(シフト移動)です。
/// 行き先の決定(TeleportationArea、レイキャスト、着地判定、リティクル)は XRI のテレポートをそのまま使い、
/// 移動の適用だけを差し替えています。
/// - 移動は等速です。加減速を入れると速度変化で酔いを誘発するため、イージングは使いません。
/// - 回転(MatchOrientation)は補間せず、移動の開始時に一度だけ適用します。
/// - 移動中は LocomotionMediator 上で Moving 状態を保つため、次のテレポート要求は受け付けられません。
/// - shiftEnabled を無効にすると、基底クラスと同じ瞬間テレポートになります。
/// </summary>
[AddComponentMenu("XR/Locomotion/Shift Teleportation Provider", 11)]
public class ShiftTeleportationProvider : TeleportationProvider
{
[Header("Shift")]
[SerializeField, Tooltip("シフト移動を使います。無効にすると瞬間テレポートになります")]
private bool shiftEnabled = true;
[SerializeField, Min(0.1f), Tooltip("移動速度(メートル毎秒)。等速で移動します")]
private float shiftSpeed = 20.0f;
[SerializeField, Min(0.01f), Tooltip("1 回の移動にかける最大時間(秒)。距離が長い場合はこの時間に収まるよう速くなります")]
private float maxDuration = 0.25f;
private readonly ShiftGroundPosition _shiftTransformation = new ShiftGroundPosition();
private bool _isShifting;
/// <summary>
/// シフト移動を使うかどうか。false にすると瞬間テレポートになります。快適性の設定から切り替える用途です。
/// </summary>
public bool ShiftEnabled
{
get => shiftEnabled;
set => shiftEnabled = value;
}
/// <summary>
/// 移動速度(メートル毎秒)
/// </summary>
public float ShiftSpeed
{
get => shiftSpeed;
set => shiftSpeed = Mathf.Max(0.1f, value);
}
/// <summary>
/// 1 回の移動にかける最大時間(秒)
/// </summary>
public float MaxDuration
{
get => maxDuration;
set => maxDuration = Mathf.Max(0.01f, value);
}
/// <summary>
/// シフト移動中かどうか
/// </summary>
public bool IsShifting => _isShifting;
/// <summary>
/// 移動中に届いた要求は受け付けません。受け付けると移動終了の直後に次の移動が連鎖して起きるためです。
/// </summary>
public override bool QueueTeleportRequest(TeleportRequest teleportRequest)
{
if (shiftEnabled && _isShifting)
{
return false;
}
return base.QueueTeleportRequest(teleportRequest);
}
protected override void Update()
{
if (!shiftEnabled)
{
base.Update();
return;
}
if (_isShifting)
{
StepShift();
return;
}
if (!validRequest)
{
return;
}
// 前回の移動が Ended → Idle に戻るまで 1 フレームかかるため、Idle になるまで待ちます
if (locomotionState != LocomotionState.Idle)
{
return;
}
if (!TryStartLocomotionImmediately())
{
return;
}
BeginShift();
}
protected override void OnLocomotionEnding()
{
base.OnLocomotionEnding();
_isShifting = false;
}
/// <summary>
/// 回転を一度だけ適用し、目的地への補間を開始します。
/// 始点(足元位置)と所要時間は、変換が最初に適用されるときに XRBodyTransformer の評価器で確定します。
/// </summary>
private void BeginShift()
{
ApplyOrientationOnce();
_shiftTransformation.Begin(currentRequest.destinationPosition, shiftSpeed, maxDuration);
_isShifting = true;
if (!TryQueueTransformation(_shiftTransformation))
{
FinishShift();
}
}
/// <summary>
/// 経過時間ぶん進めて変換を積みます。目的地に着いたら移動を終了します。
/// </summary>
private void StepShift()
{
_shiftTransformation.Advance(Time.deltaTime);
bool queued = TryQueueTransformation(_shiftTransformation);
bool finished = _shiftTransformation.IsComplete || !queued;
if (finished)
{
FinishShift();
}
}
private void FinishShift()
{
_isShifting = false;
validRequest = false;
TryEndLocomotion();
}
/// <summary>
/// MatchOrientation に応じた向きの変換を、移動開始時に一度だけ積みます。
/// 基底クラスの瞬間テレポートと同じ変換オブジェクトを使います。
/// </summary>
private void ApplyOrientationOnce()
{
switch (currentRequest.matchOrientation)
{
case MatchOrientation.WorldSpaceUp:
upTransformation.targetUp = Vector3.up;
TryQueueTransformation(upTransformation);
break;
case MatchOrientation.TargetUp:
upTransformation.targetUp = currentRequest.destinationRotation * Vector3.up;
TryQueueTransformation(upTransformation);
break;
case MatchOrientation.TargetUpAndForward:
upTransformation.targetUp = currentRequest.destinationRotation * Vector3.up;
TryQueueTransformation(upTransformation);
forwardTransformation.targetDirection = currentRequest.destinationRotation * Vector3.forward;
TryQueueTransformation(forwardTransformation);
break;
case MatchOrientation.None:
break;
default:
Assert.IsTrue(false, $"[{GetType().Name}] Unhandled {nameof(MatchOrientation)} : {currentRequest.matchOrientation}");
break;
}
}
/// <summary>
/// 足元位置を始点から目的地へ等速で補間する変換です。
/// 始点は最初の Apply で XRBodyTransformer の評価器から取るため、XRBodyGroundPosition と同じ基準で動きます。
/// </summary>
private sealed class ShiftGroundPosition : IXRBodyTransformation
{
private Vector3 _destination;
private Vector3 _start;
private float _speed;
private float _maxDuration;
private float _duration;
private float _elapsed;
private bool _hasStart;
public bool IsComplete => _hasStart && _elapsed >= _duration;
public void Begin(Vector3 destination, float speed, float maxDuration)
{
_destination = destination;
_speed = speed;
_maxDuration = maxDuration;
_elapsed = 0.0f;
_duration = 0.0f;
_hasStart = false;
}
public void Advance(float deltaTime)
{
_elapsed += deltaTime;
}
public void Apply(XRMovableBody body)
{
if (!_hasStart)
{
CaptureStart(body);
}
float progress = _duration > 0.0f ? Mathf.Clamp01(_elapsed / _duration) : 1.0f;
Vector3 target = Vector3.Lerp(_start, _destination, progress);
Transform origin = body.originTransform;
origin.position = target + origin.position - body.GetBodyGroundWorldPosition();
}
/// <summary>
/// 始点を足元位置で確定し、距離から所要時間を決めます。所要時間は最大時間で頭打ちにします。
/// </summary>
private void CaptureStart(XRMovableBody body)
{
_start = body.GetBodyGroundWorldPosition();
float distance = Vector3.Distance(_start, _destination);
_duration = Mathf.Min(distance / _speed, _maxDuration);
_hasStart = true;
}
}
}
XR Origin プレハブへの組み込み
XR Origin プレハブの Teleportation GameObject に付いている Teleportation Provider を、新しいクラスに差し替えます。
- プレハブを開き、
TeleportationGameObject を選択する -
Teleportation Providerコンポーネントの Inspector を Debug モードにし、ScriptにShiftTeleportationProviderをドラッグする - 保存する
補足: アセンブリの制約
XR Origin プレハブが独自の asmdef を持つパッケージやフォルダに属している場合、プレハブから参照するクラスはそのアセンブリから見える場所に置く必要があります。プレハブがパッケージ内にあるのにクラスを Assets/ 側に置くと、プレハブからは参照できません。
トンネリングビネットを追加する
移動中に視野の周辺を暗くするビネットは、XRI の Starter Assets に TunnelingVignette プレハブとして含まれています。
サンプルアセットの取り込み
Starter Assets をインポートしていない場合、PackageCache から 5 つのアセットを .meta ごとコピーします。GUID を維持することで、プレハブ → マテリアル → シェーダー → メッシュの参照が壊れません。
SRC="Library/PackageCache/com.unity.xr.interaction.toolkit@<hash>/Samples~/Starter Assets/TunnelingVignette"
DST="Assets/<任意のフォルダ>/TunnelingVignette"
mkdir -p "$DST"
cp "$SRC"/TunnelingVignette.prefab* "$SRC"/TunnelingVignette.mat* "$SRC"/TunnelingVignette.shader* \
"$SRC"/TunnelingVignetteSG.shadergraph* "$SRC"/TunnelingVignetteHemisphere.fbx* "$DST"/
Package Manager の Samples から Starter Assets をインポートしても同じものが入りますが、サンプル一式が丸ごと入るので不要なものが増えます。
配置と登録
-
TunnelingVignette.prefabを Main Camera の子として置く(位置・回転はゼロ)。半球メッシュがカメラを覆い、シェーダーで視野周辺を暗くします -
TunnelingVignetteControllerのLocomotion Vignette Providersにリスト要素を 1 つ追加し、Locomotion ProviderにShiftTeleportationProviderを割り当ててEnabledを有効にする - ビネットのパラメータ(開口サイズ、フェザー、ease in/out 時間)は既定値のまま採用しました
ビネットのあり/なしは、このリスト要素の Enabled チェックで切り替えられます。
パラメータの調整指針
| パラメータ | 既定値 | 範囲の目安 | 備考 |
|---|---|---|---|
shiftSpeed |
20 m/s | 15〜25 | 遅いとスムーズロコモーションに近づき酔いやすい |
maxDuration |
0.25 秒 | 0.2〜0.3 | 長距離での頭打ち。長いと酔いやすい |
shiftEnabled |
true | — | false で瞬間テレポート。快適性設定から ShiftEnabled プロパティで切替 |
ビネット Enabled
|
true | — |
TunnelingVignetteController のリスト要素で切替 |
まとめ
- XRI 3.x のテレポートは
TeleportationProviderのUpdateとQueueTeleportRequestが virtual なので、継承で移動の適用だけを差し替えられる - 行き先の決定(床、レイキャスト)は一切変更しなくてよい
- 酔い対策は「等速・回転は一度だけ・ビネットは XRI 標準」の 3 点
継承で済むおかげで変更ファイルは .cs 1 本とプレハブの差し替えだけに収まりました。テレポートを採用している XRI 3.x のプロジェクトであれば、短時間で試せると思います。


