3、运动系统实现:瞬移机制、平滑移动、转向系统、碰撞检测与边界处理

好,咱们直接进入正题。运动系统,说白了就是让用户在虚拟世界里「走起来」。但VR里的移动跟传统游戏完全是两码事——你让用户用摇杆推着走,他可能三秒就晕了。我最早做VR项目时,就踩过这个坑,用户反馈说「像坐在颠簸的卡车上」,后来才明白,运动系统的核心不是「怎么动」,而是「怎么不晕」。

3.1 瞬移机制:最稳妥的移动方式

瞬移,是目前VR里最主流的移动方案。它不产生连续视觉流,所以几乎不会引起晕动症。我个人习惯把瞬移拆成三个环节:定位、确认、执行。

3.1.1 抛物线定位

用户按下手柄的触摸板或摇杆,从手柄位置发射一条抛物线。这条线落到地面或物体上,显示一个标记点。用户松开按键,就瞬移到那个位置。

实现抛物线,核心是模拟物理轨迹。我一般用 UWorld::LineTraceSingleByChannel 做射线检测,配合重力模拟。代码大概长这样:

// 在 Character 或 PlayerController 中
void AVRPlayerController::UpdateTeleportPath()
{
    FVector Start = MotionController->GetComponentLocation();
    FVector Forward = MotionController->GetForwardVector();
    FVector Velocity = Forward * LaunchSpeed;

    TArray<FVector> PathPoints;
    FVector CurrentPos = Start;
    FVector CurrentVel = Velocity;

    for (int32 i = 0; i < 30; i++) // 30个采样点
    {
        FVector NextPos = CurrentPos + CurrentVel * 0.05f;
        CurrentVel += FVector(0, 0, -980.f) * 0.05f; // 重力

        FHitResult Hit;
        if (GetWorld()->LineTraceSingleByChannel(Hit, CurrentPos, NextPos, ECC_Visibility))
        {
            PathPoints.Add(Hit.Location);
            break; // 碰到地面就停止
        }
        PathPoints.Add(NextPos);
        CurrentPos = NextPos;
    }

    // 更新抛物线可视化(Spline或LineBatch)
    UpdatePathVisual(PathPoints);
}
我的经验:抛物线采样点别太多,30个足够。太多会卡,尤其是移动端VR。另外,记得把碰撞通道设为「Teleport」专用通道,避免跟角色胶囊体自己碰撞。

3.1.2 瞬移执行与旋转

用户松开按键后,我们拿到目标位置。但有个细节:要不要自动旋转?我个人建议,让用户自己决定。可以在瞬移时保持原有朝向,也可以提供一个「瞬移后朝向手柄方向」的选项。

void AVRPlayerController::ExecuteTeleport(FVector TargetLocation)
{
    // 先做合法性检查
    if (!IsTeleportLocationValid(TargetLocation)) return;

    // 移动角色
    GetPawn()->SetActorLocation(TargetLocation);

    // 可选:旋转朝向
    if (bRotateToControllerOnTeleport)
    {
        FRotator NewRot = MotionController->GetComponentRotation();
        NewRot.Pitch = 0; // 保持水平
        NewRot.Roll = 0;
        GetPawn()->SetActorRotation(NewRot);
    }
}
注意:瞬移时一定要做碰撞检测。我曾经遇到过用户瞬移到墙里,结果卡住了。解决办法是:在目标位置做一次胶囊体碰撞检测,如果重叠,就沿法线方向向外推,或者直接拒绝这次瞬移。

3.2 平滑移动:需要谨慎使用的方案

平滑移动,就是传统FPS里的摇杆走路。在VR里,这玩意儿是晕动症的头号元凶。但有些场景不得不用,比如需要连续移动的射击游戏。我的建议是:默认关闭,让用户手动开启。

3.2.1 实现方式

UE里实现平滑移动很简单,直接修改Character的移动组件就行。但VR里有个关键点:移动方向应该基于头显朝向,而不是角色朝向。

void AVRCharacter::MoveForward(float Value)
{
    if (Value != 0.0f)
    {
        // 获取头显的朝向
        FVector Direction = GetVRHead()->GetForwardVector();
        Direction.Z = 0; // 保持水平
        AddMovementInput(Direction, Value);
    }
}

为什么用头显朝向?因为用户看哪就往哪走,符合直觉。如果用角色朝向,用户转头时移动方向不变,很容易晕。

3.2.2 防晕技巧

我总结了几条实用技巧:

  • 速度限制:移动速度别超过 2m/s,快了必晕。
  • 加速度/减速度:别突然启动或停止,加个缓动曲线。
  • 视野限制:移动时缩小视野(像隧道视野),减少视觉流刺激。
  • 头部锁定:移动时如果用户转头,移动方向跟着转,别拧着来。

核心原则:平滑移动的舒适度,取决于「视觉流」和「前庭觉」的匹配程度。匹配度越高,越不晕。所以,任何让视觉流变得「不自然」的操作,都要避免。

3.3 转向系统:别让用户「拧麻花」

转向,是VR运动系统里最容易被忽视的部分。很多新手直接用手柄摇杆转向,结果用户玩十分钟就晕了。为什么?因为真实世界里,你转头时内耳前庭会感知到旋转,但VR里只有视觉旋转,没有物理旋转,大脑就打架了。

3.3.1 快转(Snap Turn)

快转是目前最主流的方案。用户按一下摇杆左/右,视角瞬间旋转一个固定角度(通常是15°、30°或45°)。因为旋转是瞬间完成的,视觉流没有连续变化,所以不容易晕。

void AVRPlayerController::SnapTurn(float Direction)
{
    float SnapAngle = 30.0f; // 每步30度
    FRotator NewRot = GetControlRotation();
    NewRot.Yaw += Direction * SnapAngle;
    GetPawn()->SetActorRotation(NewRot);
}

我个人习惯提供三个档位:15°(精细)、30°(标准)、45°(快速)。用户可以根据自己的敏感度选择。

3.3.2 平滑转向

平滑转向就是连续旋转,跟平滑移动一样,容易晕。但有些用户就是喜欢,觉得快转太「跳」。我的做法是:默认关闭,但提供选项。如果用户开启,我会把旋转速度限制在 30°/s 以内,并且加上缓动。

避坑指南:我曾经做过一个项目,用户反馈「转向时画面撕裂」。后来发现是旋转速度太快,帧率跟不上。解决办法是:把旋转跟帧率解耦,用 DeltaTime 控制旋转量,而不是每帧固定转多少。

3.4 碰撞检测与边界处理

运动系统里,碰撞检测不只是「别撞墙」,还包括「别掉出边界」、「别卡在物体里」、「别走到现实世界的墙前面」。嗯,这里要注意,VR的边界处理比传统游戏复杂得多。

3.4.1 角色碰撞

UE里Character自带胶囊体碰撞,但VR里需要调整。因为用户的实际头部位置可能超出胶囊体范围(比如低头看脚下)。我一般把胶囊体高度设成 1.8m,半径 0.3m,然后开启 ECollisionEnabled::QueryAndPhysics

但有个坑:如果用户蹲下或跳起,胶囊体高度需要动态调整。我建议用 SetCapsuleSize 实时更新,但要注意性能。

3.4.2 边界检测(Chaperone系统)

SteamVR和Oculus都有内置的Chaperone(安全边界)系统,但UE里需要我们自己处理。我的做法是:在玩家周围画一个圆形或矩形边界,当用户靠近边界时,显示警告网格。

void AVRPlayerController::CheckBoundary()
{
    FVector HeadPos = GetVRHead()->GetComponentLocation();
    float DistanceToCenter = HeadPos.Size2D(); // 假设原点为房间中心

    if (DistanceToCenter > BoundaryRadius - 0.5f) // 接近边界
    {
        // 显示警告网格
        ShowBoundaryWarning(true);
        // 可选:阻止继续移动
        if (DistanceToCenter > BoundaryRadius)
        {
            // 把用户拉回边界内
            FVector ClampedPos = HeadPos;
            ClampedPos.Z = 0;
            ClampedPos = ClampedPos.GetClampedToMaxSize2D(BoundaryRadius);
            GetPawn()->SetActorLocation(ClampedPos + FVector(0,0, GetPawn()->GetActorLocation().Z));
        }
    }
    else
    {
        ShowBoundaryWarning(false);
    }
}
重要:边界检测必须在每帧执行,而且要用头显位置,不是角色位置。因为用户可能身体在边界内,但头伸到了边界外。我曾经见过用户为了捡一个虚拟物品,整个人探出边界,结果撞到现实中的墙。所以,边界检测的「触发点」一定是头显。

3.4.3 防卡死机制

用户瞬移或移动时,可能会卡进物体里。我一般做两层防护:

  1. 移动前检测:在目标位置做胶囊体碰撞检测,如果有重叠,拒绝移动。
  2. 移动后修正:如果还是卡进去了(比如因为物理模拟),用 GetOverlappingComponents 检测,然后沿最近的法线方向推出去。
void AVRPlayerController::ResolveOverlap()
{
    TArray<UPrimitiveComponent*> Overlapping;
    GetPawn()->GetOverlappingComponents(Overlapping);

    for (auto& Comp : Overlapping)
    {
        if (Comp->IsCollisionEnabled())
        {
            FVector PushDir = GetPawn()->GetActorLocation() - Comp->GetComponentLocation();
            PushDir.Z = 0;
            PushDir.Normalize();
            GetPawn()->AddActorWorldOffset(PushDir * 10.0f);
        }
    }
}

这段代码很粗暴,但够用。实际项目中,我会配合 FHitResultNormal 来做更精确的推出。

3.5 综合建议

最后,我分享几个项目里总结出来的经验:

  • 默认用瞬移+快转:这是最安全的组合,适合90%的用户。
  • 平滑移动和转向作为选项:让用户自己选,但默认关闭。
  • 边界检测一定要做:别指望用户自己注意安全,VR里用户很容易忘我。
  • 测试时多找几个人:每个人对晕的敏感度不同,你觉得不晕,不代表别人不晕。

好了,运动系统这部分就讲到这里。下一章我们会聊交互系统,包括抓取、投掷和UI交互。到时候再细说。