diff --git a/Basis/Packages/com.basis.framework/Camera/BasisHandHeldCameraInteractable.cs b/Basis/Packages/com.basis.framework/Camera/BasisHandHeldCameraInteractable.cs
index be5c3ed93..aee8aa5fd 100644
--- a/Basis/Packages/com.basis.framework/Camera/BasisHandHeldCameraInteractable.cs
+++ b/Basis/Packages/com.basis.framework/Camera/BasisHandHeldCameraInteractable.cs
@@ -49,6 +49,10 @@ public abstract class BasisHandHeldCameraInteractable : BasisPickupInteractable
[Range(5f, 25f)]
public float rotationSmoothing = 15f;
+ /// VR fly-mode yaw turn rate at full pilot-stick deflection (degrees/second). Rate-based:
+ /// the heading holds when the stick re-centres.
+ public float yawRate = 90f;
+
[Header("Cinematic Controls")]
/// Whether to use momentum/inertia for movement.
public bool useMomentum = true;
@@ -125,8 +129,11 @@ public abstract class BasisHandHeldCameraInteractable : BasisPickupInteractable
// VR fly mode state
private bool isVRFlying = false;
- private bool vrThumbstickClickPrev = false;
- private Quaternion vrControllerRotation = Quaternion.identity;
+ private bool vrToggleButtonPrev = false;
+
+ /// Which hand held the camera at launch — it becomes the RC "Mode 2" pilot stick
+ /// (throttle + yaw); the other hand carries the free stick (pitch + roll).
+ private bool pilotIsLeftHand = false;
private bool selfieRotationEnabled = false;
/// Where to pin the camera transform.
@@ -483,57 +490,99 @@ private void PollDesktopControl(BasisInput DesktopEye)
}
///
- /// VR fly-mode control: toggles fly mode on thumbstick click (edge-detected)
- /// and captures controller rotation each frame for camera aiming.
+ /// VR fly-mode control: enter/exit on the B/Y face button (edge-detected). Entering requires the
+ /// camera to be held, so the holding hand becomes the pilot; exiting reads the pilot hand directly
+ /// since the camera has detached and may no longer register as interacting.
///
private void PollVRControl()
{
- if (GetActiveVRInput(out BasisInputWrapper vrInput))
+ string className = nameof(BasisHandHeldCameraInteractable);
+
+ if (!isVRFlying)
{
- BasisInputState inputState = vrInput.Source.CurrentInputState;
- string className = nameof(BasisHandHeldCameraInteractable);
+ if (!GetActiveVRInput(out BasisInputWrapper vrInput))
+ {
+ vrToggleButtonPrev = false;
+ return;
+ }
- // Toggle fly mode on thumbstick click (edge detection)
- bool thumbstickClick = inputState.Primary2DAxisClick;
- if (thumbstickClick && !vrThumbstickClickPrev)
+ bool togglePressed = vrInput.Source.CurrentInputState.SecondaryButtonGetState;
+ if (togglePressed && !vrToggleButtonPrev)
{
- if (isVRFlying)
- {
- // Exit VR fly mode — return camera to hand
- isVRFlying = false;
- if (!LookLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove LookLock");
- if (!MovementLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove MovementLock");
- if (!CrouchingLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove CrouchingLock");
-
- PinSpace = CameraPinSpace.HandHeld;
- velocityMomentum = Vector3.zero;
- rotationMomentum = 0f;
- }
- else
- {
- // Enter VR fly mode
- isVRFlying = true;
- LookLock.Add(className);
- MovementLock.Add(className);
- CrouchingLock.Add(className);
+ pilotIsLeftHand = Inputs.leftHand.GetState() == BasisInteractInputState.Interacting;
+ EnterVRFly(className);
+ }
+ vrToggleButtonPrev = togglePressed;
+ }
+ else
+ {
+ var pilot = pilotIsLeftHand ? Inputs.leftHand.Source : Inputs.rightHand.Source;
+ bool togglePressed = pilot != null && pilot.CurrentInputState.SecondaryButtonGetState;
+ if (togglePressed && !vrToggleButtonPrev)
+ {
+ ExitVRFly(className);
+ }
+ vrToggleButtonPrev = togglePressed;
+ }
+ }
- PinSpace = CameraPinSpace.WorldSpace;
+ /// Enters VR fly mode: takes the player locks, detaches the camera to world space,
+ /// and seeds the heading and motion state from the current camera pose.
+ private void EnterVRFly(string className)
+ {
+ isVRFlying = true;
+ LookLock.Add(className);
+ MovementLock.Add(className);
+ CrouchingLock.Add(className);
- HHC.captureCamera.transform.GetPositionAndRotation(out smoothedPosition, out smoothedRotation);
+ PinSpace = CameraPinSpace.WorldSpace;
- // Initialize rotation tracking from current camera orientation
- Vector3 euler = smoothedRotation.eulerAngles;
- currentPitch = targetPitch = NormalizeAngle(euler.x);
- currentYaw = targetYaw = NormalizeAngle(euler.y);
- }
- }
- vrThumbstickClickPrev = thumbstickClick;
+ HHC.captureCamera.transform.GetPositionAndRotation(out smoothedPosition, out smoothedRotation);
+ currentYaw = NormalizeAngle(smoothedRotation.eulerAngles.y);
+ currentVelocity = Vector3.zero;
+ targetVelocity = Vector3.zero;
+ velocityMomentum = Vector3.zero;
+ }
- if (isVRFlying)
- {
- // Store VR controller rotation for movement direction and camera aim
- vrControllerRotation = vrInput.BoneControl.OutgoingWorldData.rotation;
- }
+ /// Exits VR fly mode: releases the player locks, returns the camera to the hand,
+ /// and clears momentum.
+ private void ExitVRFly(string className)
+ {
+ isVRFlying = false;
+ if (!LookLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove LookLock");
+ if (!MovementLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove MovementLock");
+ if (!CrouchingLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove CrouchingLock");
+
+ PinSpace = CameraPinSpace.HandHeld;
+ currentVelocity = Vector3.zero;
+ targetVelocity = Vector3.zero;
+ velocityMomentum = Vector3.zero;
+ rotationMomentum = 0f;
+ }
+
+ ///
+ /// Reads the two thumbsticks and maps them to RC "Mode 2" flight axes. The pilot hand (held the
+ /// camera at launch) carries throttle (Y) + yaw (X); the free hand carries pitch (Y) + roll (X).
+ /// Uses each stick's built-in radial deadzone.
+ ///
+ private void ReadVRFlyAxes(out float throttle, out float yaw, out float pitch, out float roll)
+ {
+ throttle = yaw = pitch = roll = 0f;
+
+ var pilot = pilotIsLeftHand ? Inputs.leftHand.Source : Inputs.rightHand.Source;
+ var free = pilotIsLeftHand ? Inputs.rightHand.Source : Inputs.leftHand.Source;
+
+ if (pilot != null)
+ {
+ Vector2 pilotStick = pilot.CurrentInputState.Primary2DAxisDeadZoned;
+ throttle = pilotStick.y;
+ yaw = pilotStick.x;
+ }
+ if (free != null)
+ {
+ Vector2 freeStick = free.CurrentInputState.Primary2DAxisDeadZoned;
+ pitch = freeStick.y;
+ roll = freeStick.x;
}
}
@@ -588,6 +637,12 @@ private void MoveCameraFlying()
{
float deltaTime = Time.deltaTime;
+ if (isVRFlying)
+ {
+ MoveVRFlying(deltaTime);
+ return;
+ }
+
if (HandleMovementInput(out Vector3 inputMovement, out float speedMultiplier))
{
UpdateMovement(inputMovement, speedMultiplier, deltaTime);
@@ -615,62 +670,75 @@ private void MoveCameraFlying()
ApplySmoothedPosition(deltaTime);
}
- /// Reads fly movement inputs and outputs a normalized movement vector + speed multiplier.
- private bool HandleMovementInput(out Vector3 movement, out float speedMultiplier)
+ ///
+ /// VR RC "Mode 2" flight step: maps the twin sticks to throttle (world-up) / yaw (rate-based heading)
+ /// / pitch (forward-back) / roll (strafe), integrates velocity with the shared acceleration and
+ /// momentum, and keeps the camera level toward the current heading. No controller-aim, no lean.
+ ///
+ private void MoveVRFlying(float deltaTime)
{
- movement = Vector3.zero;
- speedMultiplier = 1f;
-
- if (isVRFlying)
- {
- // VR path: read thumbstick from the interacting controller
- if (!GetActiveVRInput(out BasisInputWrapper vrInput))
- return false;
-
- BasisInputState state = vrInput.Source.CurrentInputState;
- Vector2 thumbstick = state.Primary2DAxisDeadZoned;
+ ReadVRFlyAxes(out float throttle, out float yaw, out float pitch, out float roll);
- // Thumbstick X = strafe, thumbstick Y = forward/back
- // Vertical movement comes from controller pitch (point up + push forward = fly up)
- movement = new Vector3(thumbstick.x, 0f, thumbstick.y);
+ // Rate-based yaw: the heading holds when the stick re-centres.
+ currentYaw = NormalizeAngle(currentYaw + yaw * yawRate * deltaTime);
- if (movement.magnitude < 0.01f)
- return false;
+ // Planar movement is relative to the heading; throttle is always world-up.
+ Vector3 planar = Quaternion.Euler(0f, currentYaw, 0f) * new Vector3(roll, 0f, pitch);
+ Vector3 targetWorldVelocity = (planar + Vector3.up * throttle) * flySpeed;
- if (movement.magnitude > 1f)
- movement.Normalize();
-
- // Grip = speed boost
- speedMultiplier = state.GripButton ? flyFastMultiplier : 1f;
- return true;
+ bool hasTranslation = throttle != 0f || pitch != 0f || roll != 0f;
+ if (hasTranslation)
+ {
+ targetVelocity = targetWorldVelocity;
+ currentVelocity = Vector3.Lerp(currentVelocity, targetVelocity, flyAcceleration * deltaTime);
+ if (useMomentum)
+ velocityMomentum = Vector3.Lerp(velocityMomentum, currentVelocity * 0.1f, deltaTime * 2f);
+ }
+ else if (useMomentum)
+ {
+ ApplyInertia(deltaTime);
}
else
{
- // Desktop path: read keyboard input
- var horizontalInput = flyCamera.horizontalMoveInput;
- var verticalInput = flyCamera.verticalMoveInput;
- var isFastMovement = flyCamera.isFastMovement;
+ currentVelocity = Vector3.zero;
+ targetVelocity = Vector3.zero;
+ }
+
+ Vector3 finalVelocity = currentVelocity + (useMomentum ? velocityMomentum : Vector3.zero);
+ smoothedPosition += finalVelocity * deltaTime;
+
+ Quaternion targetRotation = Quaternion.Euler(0f, currentYaw, 0f);
+ smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotation, rotationSmoothing * deltaTime);
+ }
+
+ /// Reads desktop fly movement inputs and outputs a normalized movement vector + speed multiplier.
+ private bool HandleMovementInput(out Vector3 movement, out float speedMultiplier)
+ {
+ movement = Vector3.zero;
+ speedMultiplier = 1f;
+
+ var horizontalInput = flyCamera.horizontalMoveInput;
+ var verticalInput = flyCamera.verticalMoveInput;
+ var isFastMovement = flyCamera.isFastMovement;
- movement = new Vector3(horizontalInput.x, verticalInput, horizontalInput.y);
+ movement = new Vector3(horizontalInput.x, verticalInput, horizontalInput.y);
- if (movement.magnitude < 0.01f)
- return false;
+ if (movement.magnitude < 0.01f)
+ return false;
- // prevent faster diagonal movement
- if (movement.magnitude > 1f)
- movement.Normalize();
+ // prevent faster diagonal movement
+ if (movement.magnitude > 1f)
+ movement.Normalize();
- speedMultiplier = isFastMovement ? flyFastMultiplier : 1f;
- return true;
- }
+ speedMultiplier = isFastMovement ? flyFastMultiplier : 1f;
+ return true;
}
/// Converts input to world velocity and applies acceleration and momentum.
private void UpdateMovement(Vector3 inputMovement, float speedMultiplier, float deltaTime)
{
- // In VR, move relative to controller orientation (point where you want to fly).
- // In desktop, move relative to the camera's current orientation.
- Quaternion orientationRef = isVRFlying ? vrControllerRotation : HHC.captureCamera.transform.rotation;
+ // Desktop: move relative to the camera's current orientation.
+ Quaternion orientationRef = HHC.captureCamera.transform.rotation;
Vector3 worldMovement = orientationRef * inputMovement;
targetVelocity = worldMovement * flySpeed * speedMultiplier;
currentVelocity = Vector3.Lerp(currentVelocity, targetVelocity, flyAcceleration * deltaTime);
@@ -696,21 +764,11 @@ private void ApplyInertia(float deltaTime)
}
}
- /// Reads fly rotation input (mouse delta) and outputs the delta if significant.
+ /// Reads desktop fly rotation input (mouse delta) and outputs the delta if significant.
private bool HandleRotationInput(out Vector2 rotationDelta)
{
rotationDelta = Vector2.zero;
- if (isVRFlying)
- {
- // VR: drive target rotation directly from controller orientation.
- // The actual rotation is applied in ApplySmoothedPosition (1:1 mapping).
- Vector3 euler = vrControllerRotation.eulerAngles;
- targetPitch = NormalizeAngle(euler.x);
- targetYaw = NormalizeAngle(euler.y);
- return false;
- }
-
// Desktop: mouse delta
var mouseInput = flyCamera.mouseInput;
@@ -750,31 +808,22 @@ private void ApplyAutoLeveling(float deltaTime)
///
/// Integrates velocity into and applies smoothed rotation
- /// with momentum-influenced smoothing.
+ /// with momentum-influenced smoothing. Desktop fly path only; the VR path is handled in
+ /// .
///
private void ApplySmoothedPosition(float deltaTime)
{
Vector3 finalVelocity = currentVelocity + (useMomentum ? velocityMomentum : Vector3.zero);
smoothedPosition += finalVelocity * deltaTime;
- if (isVRFlying)
- {
- // VR: 1:1 controller-to-camera rotation for responsive aiming
- currentPitch = targetPitch;
- currentYaw = targetYaw;
- smoothedRotation = vrControllerRotation;
- }
- else
- {
- // Desktop: smoothed rotation with momentum
- float enhancedRotationSmoothness = rotationSmoothing + rotationMomentum;
+ // Desktop: smoothed rotation with momentum
+ float enhancedRotationSmoothness = rotationSmoothing + rotationMomentum;
- currentPitch = Mathf.LerpAngle(currentPitch, targetPitch, enhancedRotationSmoothness * deltaTime);
- currentYaw = Mathf.LerpAngle(currentYaw, targetYaw, enhancedRotationSmoothness * deltaTime);
+ currentPitch = Mathf.LerpAngle(currentPitch, targetPitch, enhancedRotationSmoothness * deltaTime);
+ currentYaw = Mathf.LerpAngle(currentYaw, targetYaw, enhancedRotationSmoothness * deltaTime);
- Quaternion targetRotationQuat = Quaternion.Euler(currentPitch, currentYaw, 0f);
- smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotationQuat, rotationSmoothing * deltaTime);
- }
+ Quaternion targetRotationQuat = Quaternion.Euler(currentPitch, currentYaw, 0f);
+ smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotationQuat, rotationSmoothing * deltaTime);
}
/// Normalizes an angle to the range [-180, 180].
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
index 4c8525d9f..2762c35b7 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
@@ -155,6 +155,7 @@ public bool IsEnabled
public BasisLocks.LockContext MovementLock = BasisLocks.GetContext(BasisLocks.Movement);
public BasisLocks.LockContext CrouchingLock = BasisLocks.GetContext(BasisLocks.Crouching);
+ public BasisLocks.LockContext LookRotationLock = BasisLocks.GetContext(BasisLocks.LookRotation);
public Transform BasisLocalPlayerTransform;
private bool isEnabled = true;
public float CurrentSpeed;
@@ -245,7 +246,12 @@ public void SimulateMovement(float DeltaTime)
// Calculate the rotation amount for this frame
float rotationAmount;
- if (SMModuleControllerSettings.UsingSnapTurnAngle && BasisDeviceManagement.IsCurrentModeVR())
+ if (LookRotationLock)
+ {
+ rotationAmount = 0f;
+ isSnapTurning = false;
+ }
+ else if (SMModuleControllerSettings.UsingSnapTurnAngle && BasisDeviceManagement.IsCurrentModeVR())
{
var isAboveThreshold = math.abs(Rotation.x) > SnapTurnAbsoluteThreshold;
if (isAboveThreshold != isSnapTurning)