@@ -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