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);
}
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 防卡死机制
用户瞬移或移动时,可能会卡进物体里。我一般做两层防护:
- 移动前检测:在目标位置做胶囊体碰撞检测,如果有重叠,拒绝移动。
- 移动后修正:如果还是卡进去了(比如因为物理模拟),用
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);
}
}
}
这段代码很粗暴,但够用。实际项目中,我会配合 FHitResult 的 Normal 来做更精确的推出。
3.5 综合建议
最后,我分享几个项目里总结出来的经验:
- 默认用瞬移+快转:这是最安全的组合,适合90%的用户。
- 平滑移动和转向作为选项:让用户自己选,但默认关闭。
- 边界检测一定要做:别指望用户自己注意安全,VR里用户很容易忘我。
- 测试时多找几个人:每个人对晕的敏感度不同,你觉得不晕,不代表别人不晕。
好了,运动系统这部分就讲到这里。下一章我们会聊交互系统,包括抓取、投掷和UI交互。到时候再细说。