Skip to content

Commit 6e3f5d8

Browse files
committed
feat: VR camera fly mode with RC Mode 2 twin-stick controls
Replace the handheld camera's VR fly path with an RC "Mode 2" flight model. Launch/recall toggles on the B/Y face button (edge-detected) instead of a thumbstick click, which wears the stick over time. Holding the camera and pressing B/Y detaches it to world space and frees both hands to pilot; the holding hand becomes the pilot stick. Twin-stick mapping: - Pilot stick: throttle (world-up, heading-independent) + yaw (rate-based; the heading holds when the stick re-centres) - Free-hand stick: pitch (forward/back) + roll (strafe), both relative to the current heading The avatar is held in place while flying via the existing look, movement, and crouch locks; look lock now also suppresses VR turn. Reuses the existing fly speed / acceleration / momentum fields and adds a yaw-rate tunable. Camera stays level toward the heading (no controller-aim, no lean). The desktop fly path is unchanged.
1 parent eaf5d26 commit 6e3f5d8

1 file changed

Lines changed: 160 additions & 111 deletions

File tree

Basis/Packages/com.basis.framework/Camera/BasisHandHeldCameraInteractable.cs

Lines changed: 160 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ public abstract class BasisHandHeldCameraInteractable : BasisPickupInteractable
4949
[Range(5f, 25f)]
5050
public float rotationSmoothing = 15f;
5151

52+
/// <summary>VR fly-mode yaw turn rate at full pilot-stick deflection (degrees/second). Rate-based:
53+
/// the heading holds when the stick re-centres.</summary>
54+
public float yawRate = 90f;
55+
5256
[Header("Cinematic Controls")]
5357
/// <summary>Whether to use momentum/inertia for movement.</summary>
5458
public bool useMomentum = true;
@@ -125,8 +129,11 @@ public abstract class BasisHandHeldCameraInteractable : BasisPickupInteractable
125129

126130
// VR fly mode state
127131
private bool isVRFlying = false;
128-
private bool vrThumbstickClickPrev = false;
129-
private Quaternion vrControllerRotation = Quaternion.identity;
132+
private bool vrToggleButtonPrev = false;
133+
134+
/// <summary>Which hand held the camera at launch — it becomes the RC "Mode 2" pilot stick
135+
/// (throttle + yaw); the other hand carries the free stick (pitch + roll).</summary>
136+
private bool pilotIsLeftHand = false;
130137

131138
private bool selfieRotationEnabled = false;
132139
/// <summary>Where to pin the camera transform.</summary>
@@ -483,57 +490,99 @@ private void PollDesktopControl(BasisInput DesktopEye)
483490
}
484491

485492
/// <summary>
486-
/// VR fly-mode control: toggles fly mode on thumbstick click (edge-detected)
487-
/// and captures controller rotation each frame for camera aiming.
493+
/// VR fly-mode control: enter/exit on the B/Y face button (edge-detected). Entering requires the
494+
/// camera to be held, so the holding hand becomes the pilot; exiting reads the pilot hand directly
495+
/// since the camera has detached and may no longer register as interacting.
488496
/// </summary>
489497
private void PollVRControl()
490498
{
491-
if (GetActiveVRInput(out BasisInputWrapper vrInput))
499+
string className = nameof(BasisHandHeldCameraInteractable);
500+
501+
if (!isVRFlying)
492502
{
493-
BasisInputState inputState = vrInput.Source.CurrentInputState;
494-
string className = nameof(BasisHandHeldCameraInteractable);
503+
if (!GetActiveVRInput(out BasisInputWrapper vrInput))
504+
{
505+
vrToggleButtonPrev = false;
506+
return;
507+
}
495508

496-
// Toggle fly mode on thumbstick click (edge detection)
497-
bool thumbstickClick = inputState.Primary2DAxisClick;
498-
if (thumbstickClick && !vrThumbstickClickPrev)
509+
bool togglePressed = vrInput.Source.CurrentInputState.SecondaryButtonGetState;
510+
if (togglePressed && !vrToggleButtonPrev)
499511
{
500-
if (isVRFlying)
501-
{
502-
// Exit VR fly mode — return camera to hand
503-
isVRFlying = false;
504-
if (!LookLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove LookLock");
505-
if (!MovementLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove MovementLock");
506-
if (!CrouchingLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove CrouchingLock");
507-
508-
PinSpace = CameraPinSpace.HandHeld;
509-
velocityMomentum = Vector3.zero;
510-
rotationMomentum = 0f;
511-
}
512-
else
513-
{
514-
// Enter VR fly mode
515-
isVRFlying = true;
516-
LookLock.Add(className);
517-
MovementLock.Add(className);
518-
CrouchingLock.Add(className);
512+
pilotIsLeftHand = Inputs.leftHand.GetState() == BasisInteractInputState.Interacting;
513+
EnterVRFly(className);
514+
}
515+
vrToggleButtonPrev = togglePressed;
516+
}
517+
else
518+
{
519+
var pilot = pilotIsLeftHand ? Inputs.leftHand.Source : Inputs.rightHand.Source;
520+
bool togglePressed = pilot != null && pilot.CurrentInputState.SecondaryButtonGetState;
521+
if (togglePressed && !vrToggleButtonPrev)
522+
{
523+
ExitVRFly(className);
524+
}
525+
vrToggleButtonPrev = togglePressed;
526+
}
527+
}
519528

520-
PinSpace = CameraPinSpace.WorldSpace;
529+
/// <summary>Enters VR fly mode: takes the player locks, detaches the camera to world space,
530+
/// and seeds the heading and motion state from the current camera pose.</summary>
531+
private void EnterVRFly(string className)
532+
{
533+
isVRFlying = true;
534+
LookLock.Add(className);
535+
MovementLock.Add(className);
536+
CrouchingLock.Add(className);
521537

522-
HHC.captureCamera.transform.GetPositionAndRotation(out smoothedPosition, out smoothedRotation);
538+
PinSpace = CameraPinSpace.WorldSpace;
523539

524-
// Initialize rotation tracking from current camera orientation
525-
Vector3 euler = smoothedRotation.eulerAngles;
526-
currentPitch = targetPitch = NormalizeAngle(euler.x);
527-
currentYaw = targetYaw = NormalizeAngle(euler.y);
528-
}
529-
}
530-
vrThumbstickClickPrev = thumbstickClick;
540+
HHC.captureCamera.transform.GetPositionAndRotation(out smoothedPosition, out smoothedRotation);
541+
currentYaw = NormalizeAngle(smoothedRotation.eulerAngles.y);
542+
currentVelocity = Vector3.zero;
543+
targetVelocity = Vector3.zero;
544+
velocityMomentum = Vector3.zero;
545+
}
531546

532-
if (isVRFlying)
533-
{
534-
// Store VR controller rotation for movement direction and camera aim
535-
vrControllerRotation = vrInput.BoneControl.OutgoingWorldData.rotation;
536-
}
547+
/// <summary>Exits VR fly mode: releases the player locks, returns the camera to the hand,
548+
/// and clears momentum.</summary>
549+
private void ExitVRFly(string className)
550+
{
551+
isVRFlying = false;
552+
if (!LookLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove LookLock");
553+
if (!MovementLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove MovementLock");
554+
if (!CrouchingLock.Remove(className)) BasisDebug.LogWarning($"{className} couldn't remove CrouchingLock");
555+
556+
PinSpace = CameraPinSpace.HandHeld;
557+
currentVelocity = Vector3.zero;
558+
targetVelocity = Vector3.zero;
559+
velocityMomentum = Vector3.zero;
560+
rotationMomentum = 0f;
561+
}
562+
563+
/// <summary>
564+
/// Reads the two thumbsticks and maps them to RC "Mode 2" flight axes. The pilot hand (held the
565+
/// camera at launch) carries throttle (Y) + yaw (X); the free hand carries pitch (Y) + roll (X).
566+
/// Uses each stick's built-in radial deadzone.
567+
/// </summary>
568+
private void ReadVRFlyAxes(out float throttle, out float yaw, out float pitch, out float roll)
569+
{
570+
throttle = yaw = pitch = roll = 0f;
571+
572+
var pilot = pilotIsLeftHand ? Inputs.leftHand.Source : Inputs.rightHand.Source;
573+
var free = pilotIsLeftHand ? Inputs.rightHand.Source : Inputs.leftHand.Source;
574+
575+
if (pilot != null)
576+
{
577+
Vector2 pilotStick = pilot.CurrentInputState.Primary2DAxisDeadZoned;
578+
throttle = pilotStick.y;
579+
yaw = pilotStick.x;
580+
}
581+
if (free != null)
582+
{
583+
Vector2 freeStick = free.CurrentInputState.Primary2DAxisDeadZoned;
584+
pitch = freeStick.y;
585+
roll = freeStick.x;
537586
}
538587
}
539588

@@ -588,6 +637,12 @@ private void MoveCameraFlying()
588637
{
589638
float deltaTime = Time.deltaTime;
590639

640+
if (isVRFlying)
641+
{
642+
MoveVRFlying(deltaTime);
643+
return;
644+
}
645+
591646
if (HandleMovementInput(out Vector3 inputMovement, out float speedMultiplier))
592647
{
593648
UpdateMovement(inputMovement, speedMultiplier, deltaTime);
@@ -615,62 +670,75 @@ private void MoveCameraFlying()
615670
ApplySmoothedPosition(deltaTime);
616671
}
617672

618-
/// <summary>Reads fly movement inputs and outputs a normalized movement vector + speed multiplier.</summary>
619-
private bool HandleMovementInput(out Vector3 movement, out float speedMultiplier)
673+
/// <summary>
674+
/// VR RC "Mode 2" flight step: maps the twin sticks to throttle (world-up) / yaw (rate-based heading)
675+
/// / pitch (forward-back) / roll (strafe), integrates velocity with the shared acceleration and
676+
/// momentum, and keeps the camera level toward the current heading. No controller-aim, no lean.
677+
/// </summary>
678+
private void MoveVRFlying(float deltaTime)
620679
{
621-
movement = Vector3.zero;
622-
speedMultiplier = 1f;
623-
624-
if (isVRFlying)
625-
{
626-
// VR path: read thumbstick from the interacting controller
627-
if (!GetActiveVRInput(out BasisInputWrapper vrInput))
628-
return false;
629-
630-
BasisInputState state = vrInput.Source.CurrentInputState;
631-
Vector2 thumbstick = state.Primary2DAxisDeadZoned;
680+
ReadVRFlyAxes(out float throttle, out float yaw, out float pitch, out float roll);
632681

633-
// Thumbstick X = strafe, thumbstick Y = forward/back
634-
// Vertical movement comes from controller pitch (point up + push forward = fly up)
635-
movement = new Vector3(thumbstick.x, 0f, thumbstick.y);
682+
// Rate-based yaw: the heading holds when the stick re-centres.
683+
currentYaw = NormalizeAngle(currentYaw + yaw * yawRate * deltaTime);
636684

637-
if (movement.magnitude < 0.01f)
638-
return false;
685+
// Planar movement is relative to the heading; throttle is always world-up.
686+
Vector3 planar = Quaternion.Euler(0f, currentYaw, 0f) * new Vector3(roll, 0f, pitch);
687+
Vector3 targetWorldVelocity = (planar + Vector3.up * throttle) * flySpeed;
639688

640-
if (movement.magnitude > 1f)
641-
movement.Normalize();
642-
643-
// Grip = speed boost
644-
speedMultiplier = state.GripButton ? flyFastMultiplier : 1f;
645-
return true;
689+
bool hasTranslation = throttle != 0f || pitch != 0f || roll != 0f;
690+
if (hasTranslation)
691+
{
692+
targetVelocity = targetWorldVelocity;
693+
currentVelocity = Vector3.Lerp(currentVelocity, targetVelocity, flyAcceleration * deltaTime);
694+
if (useMomentum)
695+
velocityMomentum = Vector3.Lerp(velocityMomentum, currentVelocity * 0.1f, deltaTime * 2f);
696+
}
697+
else if (useMomentum)
698+
{
699+
ApplyInertia(deltaTime);
646700
}
647701
else
648702
{
649-
// Desktop path: read keyboard input
650-
var horizontalInput = flyCamera.horizontalMoveInput;
651-
var verticalInput = flyCamera.verticalMoveInput;
652-
var isFastMovement = flyCamera.isFastMovement;
703+
currentVelocity = Vector3.zero;
704+
targetVelocity = Vector3.zero;
705+
}
706+
707+
Vector3 finalVelocity = currentVelocity + (useMomentum ? velocityMomentum : Vector3.zero);
708+
smoothedPosition += finalVelocity * deltaTime;
709+
710+
Quaternion targetRotation = Quaternion.Euler(0f, currentYaw, 0f);
711+
smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotation, rotationSmoothing * deltaTime);
712+
}
713+
714+
/// <summary>Reads desktop fly movement inputs and outputs a normalized movement vector + speed multiplier.</summary>
715+
private bool HandleMovementInput(out Vector3 movement, out float speedMultiplier)
716+
{
717+
movement = Vector3.zero;
718+
speedMultiplier = 1f;
719+
720+
var horizontalInput = flyCamera.horizontalMoveInput;
721+
var verticalInput = flyCamera.verticalMoveInput;
722+
var isFastMovement = flyCamera.isFastMovement;
653723

654-
movement = new Vector3(horizontalInput.x, verticalInput, horizontalInput.y);
724+
movement = new Vector3(horizontalInput.x, verticalInput, horizontalInput.y);
655725

656-
if (movement.magnitude < 0.01f)
657-
return false;
726+
if (movement.magnitude < 0.01f)
727+
return false;
658728

659-
// prevent faster diagonal movement
660-
if (movement.magnitude > 1f)
661-
movement.Normalize();
729+
// prevent faster diagonal movement
730+
if (movement.magnitude > 1f)
731+
movement.Normalize();
662732

663-
speedMultiplier = isFastMovement ? flyFastMultiplier : 1f;
664-
return true;
665-
}
733+
speedMultiplier = isFastMovement ? flyFastMultiplier : 1f;
734+
return true;
666735
}
667736

668737
/// <summary>Converts input to world velocity and applies acceleration and momentum.</summary>
669738
private void UpdateMovement(Vector3 inputMovement, float speedMultiplier, float deltaTime)
670739
{
671-
// In VR, move relative to controller orientation (point where you want to fly).
672-
// In desktop, move relative to the camera's current orientation.
673-
Quaternion orientationRef = isVRFlying ? vrControllerRotation : HHC.captureCamera.transform.rotation;
740+
// Desktop: move relative to the camera's current orientation.
741+
Quaternion orientationRef = HHC.captureCamera.transform.rotation;
674742
Vector3 worldMovement = orientationRef * inputMovement;
675743
targetVelocity = worldMovement * flySpeed * speedMultiplier;
676744
currentVelocity = Vector3.Lerp(currentVelocity, targetVelocity, flyAcceleration * deltaTime);
@@ -696,21 +764,11 @@ private void ApplyInertia(float deltaTime)
696764
}
697765
}
698766

699-
/// <summary>Reads fly rotation input (mouse delta) and outputs the delta if significant.</summary>
767+
/// <summary>Reads desktop fly rotation input (mouse delta) and outputs the delta if significant.</summary>
700768
private bool HandleRotationInput(out Vector2 rotationDelta)
701769
{
702770
rotationDelta = Vector2.zero;
703771

704-
if (isVRFlying)
705-
{
706-
// VR: drive target rotation directly from controller orientation.
707-
// The actual rotation is applied in ApplySmoothedPosition (1:1 mapping).
708-
Vector3 euler = vrControllerRotation.eulerAngles;
709-
targetPitch = NormalizeAngle(euler.x);
710-
targetYaw = NormalizeAngle(euler.y);
711-
return false;
712-
}
713-
714772
// Desktop: mouse delta
715773
var mouseInput = flyCamera.mouseInput;
716774

@@ -750,31 +808,22 @@ private void ApplyAutoLeveling(float deltaTime)
750808

751809
/// <summary>
752810
/// Integrates velocity into <see cref="smoothedPosition"/> and applies smoothed rotation
753-
/// with momentum-influenced smoothing.
811+
/// with momentum-influenced smoothing. Desktop fly path only; the VR path is handled in
812+
/// <see cref="MoveVRFlying"/>.
754813
/// </summary>
755814
private void ApplySmoothedPosition(float deltaTime)
756815
{
757816
Vector3 finalVelocity = currentVelocity + (useMomentum ? velocityMomentum : Vector3.zero);
758817
smoothedPosition += finalVelocity * deltaTime;
759818

760-
if (isVRFlying)
761-
{
762-
// VR: 1:1 controller-to-camera rotation for responsive aiming
763-
currentPitch = targetPitch;
764-
currentYaw = targetYaw;
765-
smoothedRotation = vrControllerRotation;
766-
}
767-
else
768-
{
769-
// Desktop: smoothed rotation with momentum
770-
float enhancedRotationSmoothness = rotationSmoothing + rotationMomentum;
819+
// Desktop: smoothed rotation with momentum
820+
float enhancedRotationSmoothness = rotationSmoothing + rotationMomentum;
771821

772-
currentPitch = Mathf.LerpAngle(currentPitch, targetPitch, enhancedRotationSmoothness * deltaTime);
773-
currentYaw = Mathf.LerpAngle(currentYaw, targetYaw, enhancedRotationSmoothness * deltaTime);
822+
currentPitch = Mathf.LerpAngle(currentPitch, targetPitch, enhancedRotationSmoothness * deltaTime);
823+
currentYaw = Mathf.LerpAngle(currentYaw, targetYaw, enhancedRotationSmoothness * deltaTime);
774824

775-
Quaternion targetRotationQuat = Quaternion.Euler(currentPitch, currentYaw, 0f);
776-
smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotationQuat, rotationSmoothing * deltaTime);
777-
}
825+
Quaternion targetRotationQuat = Quaternion.Euler(currentPitch, currentYaw, 0f);
826+
smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRotationQuat, rotationSmoothing * deltaTime);
778827
}
779828

780829
/// <summary>Normalizes an angle to the range [-180, 180].</summary>

0 commit comments

Comments
 (0)