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