Part 10: Creating the CameraRig Control Script

This post follows: Part 9: Configure Input Settings for Camera Controls

Applying the previous configuration steps, the CameraRig is now ready to handle input from the user to position the camera in 3D Space. Let’s get going by creating a new script that will be applied to the CameraRig GameObject and name it “CameraRig.cs” in the Scripts folder. (The script is written to present the concept with minimal performance optimizations, I’ll tackle best practice coding recommendations in later posts)

The InputManager is setup with a Dual-Stick Xbox One controller in mind and currently configured with keyboard WASD-QE + ARROW keys to simplify the initial configuration — this gives us 4-axes to process for movement and view orientation with another axis to directly control our height. Each axis returns a float value between 0 and 1 which we then apply to the camera position and rotation values. To get user control input, we use the Input.GetAxis() call in the Update() method of the CameraRig script.

The code snippet define movement and turn rate values to be applied to the CameraRig. The Time.deltaTime value is applied to the change rate in the Update() method to produce an effective rate of change per second (having a TurnRate of 90f rotates the game object 90 degrees in 1 second.)

        hAxis = Input.GetAxis("Horizontal");
        vAxis = Input.GetAxis("Vertical");
        aAxis = Input.GetAxis("Altitude");
        htAxis = Input.GetAxis("HorizontalTurn");
        vtAxis = Input.GetAxis("VerticalTurn");;

        Yaw += htAxis * TurnRate * Time.deltaTime;
        Pitch += vtAxis * TurnRate * Time.deltaTime;

To reduce 3D camera movement complexity, we’ll control the current with Pitch and Yaw similar to how airplanes / helicopters / space ships manage facing direction without the Roll value. Not changing the Roll value results in a Horizon Lock, the typical human view experience of not being able to flip upside down in place, we lose control of head tilt from the controller, but can still be partially achieved by tilting the head with the VR headgear to directly roll the child camera. Pitch maps to looking up or down and Yaw maps to looking left or right configured in InputManager as VerticalTurn and HorizontalTurn respectively.

Given the position and rotation value updates, the new camera setup can be computed by simple Vector3 operations. The change in position across the 3-axes are added to the current CameraRig position with Rate and Time.deltaTime adjustments since we’re making the change within the Update() time frame. We use the CameraRig transform vectors (right, forward, up) which are relative vectors of the camera to apply the corresponding movement.

tPosition = transform.position 
            + MoveRate 
            * Time.deltaTime * (
            cameraTransform.right * hAxis +
            cameraTransform.forward * vAxis +
            Vector3.up * aAxis);

Computing the camera view requires some extra steps for limiting the values. I’m limiting the Pitch to the preferred view angles and avoid Gimbal Lock with pitch controls. The Yaw value is limited to 0 to less than 360 degrees (Utils.cs). Rotation is managed in Unity as Quaternions so Pitch and Yaw are used to compute the equivalent Quaternion using Quaternion.Euler().

        Pitch = Mathf.Clamp(Pitch, PitchMin, PitchMax);
        Yaw = Utils.LimitAngles(Yaw);
        tRotation = Quaternion.Euler(-Pitch, Yaw, 0f);

The computations are stored in variables and not directly applied to the CameraRig game object even though the values are computed with the Time.deltaTime multiplier, this is done for future flexibility on camera movements and separate the target and current camera vectors. To apply the changes, the RotateTowards and Lerp are used with a different rate of change values.

    transform.rotation = 
        Quaternion.RotateTowards(
            transform.rotation, 
            tRotation, 
            InternalTurnRate * Time.deltaTime);
    transform.position = 
        Vector3.Lerp(
            transform.position, 
            tPosition, 
            InternalMoveRate * Time.deltaTime);

Tracking Cursor

Now that we have movement and turn controls, we will quickly realize that knowing the gaze is towards the center of the screen does not necessarily mean it is a precise nor intuitive. The common solution is to have a tracking cursor designate the actual gaze location. In the project example, I use a flattened pipe created in Maya and imported into Unity to be my cursor — a quick alternative is to create a flattened sphere or cylinder in the Unity Editor using the Scale-Z values. The script exposes a public GameObject value to hold the cursor prefab which is instantiated at Start() to be used as a cursor.

The cursor is tied to the camera so computing its position is relatively straightforward, it needs to be positioned in front of the camera at a preferred offset, in VR/MR systems that position is around 2-3 meters. In the code sample we have another set of rate of change values for the cursor to have some flexibility with the cursor movement and turn speeds relative to the camera.

        // Cursor Movement Control
        tCursorPosition = transform.position
            + transform.forward
            * Offset;
        tCursorQuaternion = transform.rotation;

        // Cursor tracking
        cursor.transform.rotation = Quaternion.RotateTowards(
            cursor.transform.rotation,
            tCursorQuaternion,
            CursorTurnRate * Time.deltaTime);
        cursor.transform.position = Vector3.Lerp(
            cursor.transform.position,
            tCursorPosition,
            CursorMoveRate * Time.deltaTime);

Object Detection
We’re using Unity UI with a Physics Raycaster, this enables us to use the IsPointerOverGameObject() to detect 3D scene objects without extra scripting and coding complexities or performing extra Raycast calls since this is already done by the InputModule as pointerEvent.pointerCurrentRaycast.gameObject in the Process() method. In the example, I expose the game object as a public property GameObjectUnderPointer that gets assigned in the InputModule Process() call.

if (EventSystem.current.IsPointerOverGameObject())
        {

            if (gazeBasicInputModule != null)
            {
                currentGameObject = gazeBasicInputModule.GameObjectUnderPointer;
            }
            else
            {                
                gazeBasicInputModule = (GazeBasicInputModule)EventSystem.current.currentInputModule;
            }

            if ((currentGameObject != null) && (currentGameObject != prevGameObject))
            {
                Debug.LogFormat("Selected: {0}", currentGameObject.name);
            }
        }
        prevGameObject = currentGameObject;

CameraRig.cs

using UnityEngine;
using UnityEngine.EventSystems;

public class CameraRig : MonoBehaviour
{
    // User Controlled Change
    public float TurnRate = 90f;
    public float MoveRate = 5f;

    // Update Change Rate
    public float InternalTurnRate = 80f;
    public float InternalMoveRate = 5f;

    // Camera Rotation
    public float Yaw = 0;
    public float Pitch = 0;

    // Enforced View Limits
    public float PitchMax = 80;
    public float PitchMin = -30;

    // Cursor Tracking Object Definition
    public GameObject Cursor;
    public float Offset = 3;
    public float CursorTurnRate = 50f;
    public float CursorMoveRate = 5;

    // Target Position
    private Quaternion tRotation;
    private Vector3 tPosition;
    private Transform cameraTransform;

    // Tracking Cursor
    private GameObject cursor;
    private Vector3 tCursorPosition;
    private Quaternion tCursorQuaternion;

    // Input Axis Values
    private float hAxis;
    private float vAxis;
    private float aAxis;
    private float htAxis;
    private float vtAxis;

    private GazeBasicInputModule gazeBasicInputModule;
    private GameObject currentGameObject;
    private GameObject prevGameObject;

    void Start()
    {
        cameraTransform = GetComponentInChildren<Camera>().transform;

        tCursorPosition = transform.position + transform.forward * Offset;
        tCursorQuaternion = transform.rotation;
        cursor = Instantiate(Cursor, tCursorPosition, tCursorQuaternion) as GameObject;
    }

    void Update()
    {
        // Camera Rig Movement Control
        hAxis = Input.GetAxis("Horizontal");
        vAxis = Input.GetAxis("Vertical");
        aAxis = Input.GetAxis("Altitude");
        htAxis = Input.GetAxis("HorizontalTurn");
        vtAxis = Input.GetAxis("VerticalTurn");

        // Compute Yaw/Pitch for the current update
        Yaw += htAxis * TurnRate * Time.deltaTime;
        Pitch += vtAxis * TurnRate * Time.deltaTime;

        // Compute Position Change
        tPosition = transform.position + MoveRate * Time.deltaTime * (
            cameraTransform.right * hAxis +
            cameraTransform.forward * vAxis +
            Vector3.up * aAxis);

        // Adjust Pitch/Yaw and compute Quaternion
        Pitch = Mathf.Clamp(Pitch, PitchMin, PitchMax);
        Yaw = Utils.LimitAngles(Yaw);
        tRotation = Quaternion.Euler(-Pitch, Yaw, 0f);

        // Move/Rotate Camera Rig to computed target position/rotation
        transform.rotation = Quaternion.RotateTowards(transform.rotation, tRotation, InternalTurnRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, tPosition, InternalMoveRate * Time.deltaTime);

        // Cursor Movement Control
        tCursorPosition = transform.position
            + transform.forward
            * Offset;
        tCursorQuaternion = transform.rotation;

        // Cursor tracking
        cursor.transform.rotation = Quaternion.RotateTowards(
            cursor.transform.rotation,
            tCursorQuaternion,
            CursorTurnRate * Time.deltaTime);
        cursor.transform.position = Vector3.Lerp(cursor.transform.position,
            tCursorPosition,
            CursorMoveRate * Time.deltaTime);

        // Object Detection Handling
        if (EventSystem.current.IsPointerOverGameObject())
        {
      
            if (gazeBasicInputModule != null)
            {
                currentGameObject = gazeBasicInputModule.GameObjectUnderPointer;
            }
            else
            {                
                gazeBasicInputModule = (GazeBasicInputModule)EventSystem.current.currentInputModule;
            }

            if ((currentGameObject != null) && (currentGameObject != prevGameObject))
            {
                Debug.LogFormat("Selected: {0}", currentGameObject.name);
            }
        }
        prevGameObject = currentGameObject;
    }
}

Utils.cs

 
public static class Utils
{
    public static float LimitAngles(float angle)
    {
        float result = angle;

        while (result > 360)
            result -= 360;

        while (result < 0)
            result += 360;

        return result;
    }
}

For questions, comments or contact – follow me on Twitter @rlozada