跳到正文
Qiita · Unity 开发社区· 来源· NightOwl·· 3 小时前AI 评分75

Unity XRI 3.x 改造 TeleportationProvider 实现 Shift 移动

UnityのXR Interaction Toolkit 3 のテレポートを改造して「シフト移動(ダッシュ移動)」を実装する

正文 · AI 翻译

引言

作为 VR 应用移动方式中经典的传送(Teleport),虽然不易引起晕动症,但另一方面,由于“景色会瞬间切换”,空间连续性被打断,沉浸感也会丧失。本文介绍如何通过最小限度的改造,将 XR Interaction Toolkit(以下简称 XRI)3.x 的传送改为“瞬移移动(冲刺移动)”。

创建一个继承 XRI 的 TeleportationProvider 的类,只需替换 XR Origin 预制体上的组件,就能直接沿用指定目的地、落地判定等现有机制。

shift-locomotion_960.gif

環境

  • Unity 6
  • XR Interaction Toolkit 3

什么是换挡移动

这是《半衰期:爱莉克斯》等作品中采用的移动方式。

  • 操作与传送相同,通过指针指定目的地并确认。
  • 并非瞬间切换,而是在约0.2至0.3秒内以匀速高速滑动移动到目的地。

这种方式介于两者之间:相比传送更能保持空间感知和“正在移动的实感”,又比使用摇杆连续移动、容易引发晕动症的平滑移动(Smooth Locomotion)要舒适得多。

作为防晕动症的设计方针

在实际实现之前,我先确定了避免引发晕动症的原则。

項目 方針
移動速度 等速。イージング禁止(速度変化のベクションが酔いを誘発する)
回転 補間しない。移動開始時に一度だけ瞬時に適用する
排他制御 移動中は他のロコモーションと次のテレポートを受け付けない
壁抜け 直線補間なので経路上の壁はすり抜ける。0.25 秒なら許容とした
ビネット 使用 XRI 标准的 TunnelingVignetteController。不自行实现
与瞬间传送的切换 通过 Inspector 的标志和公共属性进行切换

视野旋转的插值比平移更容易引起晕动症,因此关键点在于仅对旋转应用瞬时变换。

实施方针:复用哪些部分,替换哪些部分

  • 复用的(不修改的): 目的地确定相关全套功能。TeleportationArea / TeleportationAnchor、Teleport Interactor 的射线检测、着陆判定、Reticle、输入绑定
  • 替换:仅应用移动。创建继承自 TeleportationProvider 的 ShiftTeleportationProvider,并将 XR Origin 预制件上现有的 TeleportationProvider 替换为此类

在 XRI 3.x 中,运动提供程序不会直接重写 XR Origin。让 LocomotionMediator 管理状态(空闲/准备中/移动中/已结束),并通过队列将 IXRBodyTransformation 传递给 XRBodyTransformer 以应用移动

位移移动通过每帧叠加“从起点到目的地的插值变换”来实现。

事先确认的事项(阅读 XRI 3 源代码后确定的事实)

是否能够基于继承实现取决于基类的设计,因此我阅读了以下 Library/PackageCache/com.unity.xr.interaction.toolkit@<hash>/Runtime/Locomotion/ 的源代码并进行了确认。

TeleportationProvider

  • public virtual bool QueueTeleportRequest(TeleportRequest) 是 virtual,因此可以覆盖
  • 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。Vignette 正在检查此值

XRBodyGroundPosition.Apply

通过移动 origin 使“脚部位置 body.GetBodyGroundWorldPosition() 与 targetPosition 一致”。也就是说,如果不以脚部位置而非 origin.position 作为插值的起点,着陆位置会与瞬间传送产生偏差。

TunnelingVignetteController

每帧查看已注册到 m_LocomotionVignetteProviders 的提供者的 isLocomotionActive,自动进行 ease in / ease out。提供者一侧无需考虑暗角效果。

TeleportationArea 的提供程序解析

当床侧的 Teleportation Provider 未设置(null)时,将由 FindAnyObjectByType<TeleportationProvider>() 来解析。只需在场景中放置一个派生类,现有的床即可直接使用。

实现: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 替换为新类。

  1. 打开预制件,选择 Teleportation GameObject
  2. Teleportation Provider 组件的 Inspector 切换为 Debug 模式,将 ShiftTeleportationProvider 拖拽到 Script 上
  3. 保存

スクリーンショット 2026-11-02 151543.png

补充:程序集约束

如果 XR Origin 预制件属于具有自定义 asmdef 的包或文件夹,则预制件引用的类必须位于该程序集可见的位置。如果预制件在包内,但类放在 Assets/ 侧,则预制件无法引用它。

添加隧道化暗角效果

移动时用于使视野边缘变暗的暗角效果已作为 TunnelingVignette 预制件包含在 XRI Starter Assets 中。

导入示例资产

如果您尚未导入 Starter Assets,请从 PackageCache 复制五个资产至 .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 也会得到相同的内容,但由于会导入整个示例套件,因此会增加不必要的资产。

配置与注册

  1. 将TunnelingVignette.prefab作为Main Camera的子对象放置(位置和旋转为零)。半球形网格覆盖相机,着色器使视野周围变暗
  2. 在TunnelingVignetteController的Locomotion Vignette Providers中添加一个列表元素,并将ShiftTeleportationProvider分配给Locomotion Provider以启用Enabled
  3. 我们采用了默认的晕影参数(光圈大小、羽化、缓入/缓出时间)

スクリーンショット 2026-11-02 151842.png

可以通过此列表元素的Enabled复选框来切换晕影的开启/关闭。

参数调整指南

参数 既定値 大致范围 備考
shiftSpeed 20 m/s 15〜25 速度过慢会接近平滑移动,更容易引起晕动症
maxDuration 0.25 秒 0.2〜0.3 长途行驶时的上限。时间越长越容易晕车
shiftEnabled true — false 实现瞬间传送。从舒适度设置中的 ShiftEnabled 属性进行切换
Vignette Enabled true — TunnelingVignetteController 的列表元素进行切换

总结

  • XRI 3.x 的传送中,由于 TeleportationProvider、Update 和 QueueTeleportRequest 是虚拟的,因此可以通过继承仅替换移动的适用逻辑
  • 无需对目的地确定(地面、射线检测)进行任何修改
  • 防晕动症对策只需遵循「匀速移动·旋转仅限一次·暗角效果采用 XRI 标准」这三点

得益于仅需继承的方式,需要修改的文件仅限于一个 .cs 文件和预制件的替换。对于采用传送功能的 XRI 3.x 项目,相信可以在短时间内进行测试。

来源:Qiita · Unity 开发社区 · qiita.com