using System; using UnityEngine; using Framework.Input.Profiles; using Framework.State; using Framework.Services; namespace Framework.Actors.Shooter { /// Lets the player to control a character's head via an HMD [RequireComponent(typeof(HeadPoser))] public class VirtualRealityHeadController : ScriptComponent, IHeadController { #region enum RecenterState /// State of the recenter functionality private enum RecenterState { /// The neutral position is not yet known NeutralPositionUnknown, /// A recenter has already been scheduled RecenterScheduled, /// Neutral position is known, a recenter is possible at any time RecenterPossible } #endregion // enum RecenterState /// Fired to pass on head rotation that was clamped public event Action ExcessHeadRotation; /// Input profiles in which the virtual reality head controller is enabled public InputSources EnabledInputProfiles = InputSources.VirtualReality; /// Camera through which the player is looking /// > /// Enabling VR in Unity will cause the camera to move around according to where /// the player's head is. So we need to take the position from this camera /// and pose the character accordingly. /// public Camera EyeCamera; /// /// Calibrates the current HMD position so it matches the animation clip's head position /// /// /// This method only works if an animator is active on the actor, posing /// the its head to its intended position before LateUpdate() is called on /// this component. If you call it on an actor without an active animator /// (or having posed the actor's head back into a neutral stance prior to /// LateUpdate()), the results will not be pretty. /// public void Recenter() { if(this.recenterState == RecenterState.RecenterPossible) { Recenter( this.neutralHeadPosition + this.headPoser.EyeOffset, this.neutralHeadOrientation ); } else { this.recenterState = RecenterState.RecenterScheduled; } } /// /// Calibrates the current HMD position so it matches the specified transform /// /// /// Position at which the current HMD position should place the camera /// /// /// Orientation in which the current HMD orientation shoudl leave the camera /// /// /// /// This is similar to what /// is supposed to do, but works with "room-scale experience," too (why should /// players who can do room-scale VR not be able to just sit on their /// chair and control a simple head?) /// /// public void Recenter(Vector3 position, Quaternion orientation) { Transform eyeCameraTransform = this.EyeCamera.transform; Transform cameraRootTransform = eyeCameraTransform.parent; // The camera's parent transform needs to be an empty, intermediate transform // to do this. Log a warning if it isn't. warnIfCameraRootNotEmpty(cameraRootTransform); // Unity's InputTracker writes the world transform of the HMD into the local // transform of the camera game object. We need the current orientation to become // the orientation specified by the caller. Quaternion hmdRotation = eyeCameraTransform.localRotation; Quaternion compensationOrientation = Quaternion.Inverse(hmdRotation); cameraRootTransform.rotation = orientation * compensationOrientation; // Figure out the translation at which the HMDs current position would place // its camera at the requested world coordinates Vector3 hmdPosition = eyeCameraTransform.localPosition; Vector3 compensationPosition = orientation * compensationOrientation * -hmdPosition; cameraRootTransform.position = compensationPosition + position; } /// Injects the instance's dependencies /// /// Input selector the instance uses to determine the active input source /// /// /// This method is called automatically by the services framework. Any parameters /// it requires will be filled out by looking up the respective services and creating /// them as needed. /// protected void Inject(IInputSelector inputSelector) { this.inputSelector = inputSelector; } /// Called when the component has been added to a game object protected override void Awake() { base.Awake(); // This implementation exclusively uses the head poser to change // the orientation of the actor's head this.headPoser = GetComponent(); if (this.headPoser == null) { Debug.LogError( "Virtual reality head controller assigned to '" + gameObject.name + "' found " + "no head poser component and will not work." ); } // The HeadMovementRange component is shared between all head controllers // to define the limits within which the actor can move its head this.headMovementRange = GetComponent(); if(this.headMovementRange == null) { this.headMovementRange = gameObject.AddComponent(); } if (this.EyeCamera == null) { this.EyeCamera = GetComponentInChildren(); if (this.EyeCamera == null) { Debug.LogError( "Virtual reality head controller assigned to '" + gameObject.name + "' has " + "no camera assigned and could not find a camera on its own, thus will not work." ); } } this.controllable = GetComponentInParent(); } /// Called once per physics frame to advance the simulate protected virtual void FixedUpdate() { // If the player doesn't control this actor, don't do head movement. if(this.controllable != null) { if(this.controllable.ControllingPlayerIndex == -1) { return; } } // If the active input profile doesn't use mouse look, don't do anything. if((this.inputSelector.ActiveSource & this.EnabledInputProfiles) == 0) { return; } // Record the target position and orientation for the head Transform eyeCameraTransform = this.EyeCamera.transform; this.posedHeadPosition = eyeCameraTransform.position; this.posedHeadOrientation = eyeCameraTransform.rotation; // If the head has moved too far, clamp it into its movement range clampHeadRotation(); } /// Reports head rotation beyond the allowed range private void clampHeadRotation() { float headPitch, headYaw; { Quaternion localHeadOrientation = ( Quaternion.Inverse(this.headPoser.GetBaseOrientation()) * this.posedHeadOrientation ); Vector3 headEulers = localHeadOrientation.eulerAngles; if(headEulers.x >= 180.0f) { headPitch = headEulers.x - 360.0f; } else { headPitch = headEulers.x; } if(headEulers.y >= 180.0f) { headYaw = headEulers.y - 360.0f; } else { headYaw = headEulers.y; } } float excessHeadPitch = headPitch; float excessHeadYaw = headYaw; this.headMovementRange.ClampPitchAndYaw(ref headPitch, ref headYaw); excessHeadPitch -= headPitch; excessHeadYaw -= headYaw; // Report the excess rotation bool excessRotationPresent = ( (Math.Abs(excessHeadPitch) >= float.Epsilon) || (Math.Abs(excessHeadYaw) >= float.Epsilon) ); if(excessRotationPresent) { OnExcessHeadRotation( Quaternion.AngleAxis(excessHeadYaw, Vector3.up) * Quaternion.AngleAxis(excessHeadPitch, Vector3.right) ); matchHeadToHmd(); } } /// Called once per physics frame protected virtual void LateUpdate() { // If the player doesn't control this actor, don't do head movement. if(this.controllable != null) { if(this.controllable.ControllingPlayerIndex == -1) { return; } } // If the active input profile doesn't use mouse look, don't do anything. if((this.inputSelector.ActiveSource & this.EnabledInputProfiles) == 0) { return; } // If the head poser component is missing, we can't do anything // (a warning will have been logged when the component was initialized). if(this.headPoser == null) { return; } // Record the neutral head position as it was left by the animator // If no animation clip is playing, this will be useless and result in // a spectacular mess if recentering is attempted :-) this.neutralHeadPosition = this.headPoser.Head.position; this.neutralHeadOrientation = this.headPoser.Head.rotation; // The animation update takes place after Update() but before LateUpdate(), // so at this point we can pick up the position of the head as it is // in the current animation clip. This position is important if the player // wishes to recenter the HMD. if(this.recenterState == RecenterState.RecenterScheduled) { this.recenterState = RecenterState.RecenterPossible; Recenter(); } else { this.recenterState = RecenterState.RecenterPossible; } matchHeadToHmd(); } /// Fires the event /// /// Excess rotation that will be reported through the event /// protected virtual void OnExcessHeadRotation(Quaternion excessRotation) { if(ExcessHeadRotation != null) { ExcessHeadRotation(excessRotation); } } /// Matches the position and orientation of the head to the HMD's private void matchHeadToHmd() { // The head position and orientation have been queried during FixedUpdate(), // but to make extra sure we've got fresh data (any lag would be very noticable // and lead to dizzyness for VR glasses) we update the state once more here. Transform eyeCameraTransform = this.EyeCamera.transform; this.posedHeadPosition = eyeCameraTransform.position; this.posedHeadOrientation = eyeCameraTransform.rotation; // Match the actor's head to the direction and position of the player's // head in the real world. this.headPoser.Pose(this.posedHeadPosition, this.posedHeadOrientation); } /// Logs a warning if the eye camera's parent transform is not empty /// Parent transform that will be checked private static void warnIfCameraRootNotEmpty(Transform cameraRootTransform) { Component[] components = cameraRootTransform.GetComponents(); int componentCount = components.Length; if(componentCount > 1) { Debug.LogWarning( "VirtualRealityHeadController was told to do a re-center. This requires " + "the camera to be parented to an intermediate empty game object that can " + "be moved into the new anchor position. The camera's parent game object " + "does not appear to be empty. Re-centering might introduce unwanted transforms." ); } } /// Transform to which the virtual reality camera is parented Transform IHeadController.VirtualRealityCameraRoot { get { return this.EyeCamera.transform.parent; } } /// Base transform in which the player controls the look direction Quaternion IHeadController.HeadBaseOrientation { get { if(this.headPoser == null) { return Quaternion.identity; } else { return this.headPoser.GetBaseOrientation(); } } } /// Where the player is looking within the head's coordinate space /// /// Append this rotation to the to /// get the direction the player is looking at in the global coordinate frame. /// Quaternion IHeadController.LocalHeadOrientation { get { return ( Quaternion.Inverse(this.headPoser.GetBaseOrientation()) * this.posedHeadOrientation ); } set { Transform eyeTransform = this.EyeCamera.transform; Recenter(eyeTransform.position, this.headPoser.GetBaseOrientation() * value); } } /// Whether this look handler is part of the active input profile /// /// It is not unusual for actors to have multiple head controllers. For example, /// there might be a mouse look head controller and a virtual reality head controller /// that are switched between depending on the input profile the game runs with. /// This property allows the actor controller to figure out which one to talk to. /// bool IHeadController.IsActive { get { if(this.controllable != null) { if(this.controllable.ControllingPlayerIndex == -1) { return false; } } return ((this.inputSelector.ActiveSource & this.EnabledInputProfiles) != 0); } } /// Input selector from which the active input source will be queried private IInputSelector inputSelector; /// Tracks, if present, whether the player is controlling this actor private IControllable controllable; /// Head poser the mouse look controller uses to move the head private HeadPoser headPoser; /// Controls how far the head can be moved on each axis private HeadMovementRange headMovementRange; /// Current state of the recenter functionality private RecenterState recenterState; /// Position of the head when it is neutral private Vector3 neutralHeadPosition; /// Orientation of the head when it is neutral private Quaternion neutralHeadOrientation; /// Current (global) position of the actor's head private Vector3 posedHeadPosition; /// Current (global) orientation of the actor's head private Quaternion posedHeadOrientation; } } // namespace Framework.Actors.Shooter