Lerping Cameras in Unity

In Unity projects, especially for newcomers, creating a smooth camera action can be especially frustrating (it has always been frustrating for me). A camera script feels like it should be a simple piece of code, and generally, it can be – if you know the right API calls to make and how they work. This post describes the process of creating a simple, free-floating, lerping camera.

Lerping

Lerping is short for “Linearly Interpolating.” It is the process by which we gradually converge on a target position in Unity, as opposed to instantaneously appearing there. Lerping makes for natural, smooth motions, as opposed to the abrupt or instantaneous movement caused by directly manipulating an object’s transform. Lerping can be used on objects out in the world space or on the camera itself (which is also an object out in world space).

Outline

Our camera script will essentially have two phases: during the Update() method, we will collect input data from the user to determine their target position and target rotation. The target position and target rotation are the orientations the user indicates with their keyboard and mouse control.

During the LateUpdate() method, which is essentially another update method that simply happens after Update(), we will linearly interpolate the camera towards the target position and towards the target rotation.

What this means is that the camera may continue to move after the user has ceased generating input – lerping towards the desired position and rotation will take time.

Our camera script will have the basic program outline.

public class CameraControl : MonoBehaviour
{
    // GameObjects ////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    public Camera camera;

    // Constants //////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    // ...

    // Variables //////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    // ...

    // Start //////////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////
    void Start()
    {
        // Set the camera's preliminary start conditions.
    }

    // Update /////////////////////////////////////////////////////////////////
    // Detect Maneuvers //////////////////////////////////////////////////////
    void Update()
    {
         // Collect the translational and rotational input data from the player.
    }

    // Late Update ////////////////////////////////////////////////////////////
    // Lerp toward target position and rotation //////////////////////////////
    void LateUpdate()
    {
        // Lerp toward target position at all times.
        // Lerp toward target rotation at all times.
    }
}

Notice that this script has a public variable, camera. This is because our script will not actually be attached to the camera object. Our camera will be “mounted” on a GameObject that represents the player. Our script, CameraControl, will be an element of the player object, with a reference to the child camera as a public variable.

Attaching the camera to a player object allows the “player” to be an entity out in world space, potentially with a hitbox, collider, width and height, casting shadows, etc. Even if you don’t plan on making your “player” an interacting, colliding entity, I still suggest mounting your camera on a GameObject just as a matter of good practice – in PISES, my cameras are ‘non-interactive’ but I still find utility in having them mounted on an object (that I, in my projects, affectionately call “Observer”).

Start Block

In our Start() block, we need simply to synchronize our camera and our player object and then designate our target position and rotations. To start, our target position and rotation will match our current position and rotation (we will start at rest).

    void Start()
    {
        // Align camera and creator.
        camera.transform.forward = transform.forward;

        // Set target position
        targetPosition = transform.position; 
        targetRotation = transform.rotation;
        Cursor.visible = false;
    }

Update Block: Rotation

In our update block, we need to gather rotational data and translational data.

Rotations are generated by either mouse input or, in this system, Q/E keyboard input for roll. This control scheme seeks to emulate a typical spaceflight game / sim – I find that having mouse and QE rotational control generally satisfies all of my maneuverability needs in PISES. This is also how mouse-keyboard work in Elite Dangerous, probably my favorite spaceflight game, which I wanted to emulate for this camera.

Mouse Input will dictate X-Y rotation. Remember, in unity, Z is forward, X is “right” and Y is “up,” relative to the camera. So, mouse movement does not generate roll, it generates pan. Think about it like eyes in your head: mouse movement changes the forward vector of your nose, while Q/E control would control “ear-to-shoulder” movement (z-axis rotation).

    // Update /////////////////////////////////////////////////////////////////
    // Detect Maneuvers //////////////////////////////////////////////////////
    void Update()
    {
        // Perform Rotations ////////////////////////////////////////////////
        ////////////////////////////////////////////////////////////////////

        // Rotate Camera based on XY input
        Vector2 delta = Vector2.zero;
        delta.y += Input.GetAxis("Mouse X");
        delta.x -= Input.GetAxis("Mouse Y");
        Quaternion deltaRotation = Quaternion.Euler(delta.x * lookSpeed, delta.y * lookSpeed, 0);

        // ...
        // ...

This code is at first counter-intuitive. Why is our delta-Y the mouse’s X axis? Why is our delta-X the mouse’s Y axis?

The answer lies in how the Quaternion stores data. The Quaternion represents a rotation using a set of Euler angles; that is, its first argument is the rotation about the X axis, its second argument is rotation about the Y axis, and its third argument is rotation about the Z axis.

If we were to move our mouse from screen-center directly upwards, we would want the camera to pitch up; that is, in response to a mouse movement along the screen’s Y axis, we would want the camera to rotate about its local X axis. Conversely, if we were to move our mouse from screen-center directly right, we would want the camera to yaw right; that is, in response to a mouse movement along the screen’s X axis, we would want the camera to rotate about its local Y axis. This is why our delta-vector’s X and Y components seem, at first, to be inverted.

Naturally, if we were to move our mouse from screen-center at an up-right 45 degree angle, we would want both X and Y components of rotation. In none of these cases do we desire Z rotation (roll).

In this code block we also introduce our first constant: lookSpeed, to be declared at the top of the class. Look speed will vary depending on your application and its scales. Mine is set to 5f, and in a deliverable software, it would be user-configurable (or in a game, perhaps spacecraft or thruster dependent).

With our X-Y rotation in hand, we now need to collect a Z rotation and combine it with our XY rotation.

        // Roll camera based on QE input
        if (Input.GetKey(KeyCode.Q))
        {deltaRotation *= Quaternion.Euler(0,0,rollSpeed);}
        if (Input.GetKey(KeyCode.E))
        {deltaRotation *= Quaternion.Euler(0,0,-rollSpeed);}

        // Set target rotation
        targetRotation *= deltaRotation;

As you can see, generating a roll quaternion is simple: for E, we want to roll positively around the forward (Z) axis. For Q, we want to roll negatively around the forward (Z) axis. Here, we introduce another constant: rollSpeed, which for me, is set to 6f. Once again, this will change depending on your application, taste, and for any deliverable software, should probably be configurable (or in a game, perhaps spacecraft or thruster dependent).

You might also notice that we are multiplying our “roll” quaternion with our “pan” quaternion. Multiplying Quaternions combines them. This allows the user to be both panning and rolling simultaneously, generating rotation around all three axes.

Finally, we combine our delta rotation with our current target rotation. This is a key step, often overlooked by beginners. Remember, our generated rotation is only a delta rotation; a change in rotation. We need to combine it with our current standing orientation in order to come up with our absolute desired orientation.

Update Block: Translation

With the tricky part of the update out of the way, we can now capture translation data.

        // Perform Translations /////////////////////////////////////////////
        ////////////////////////////////////////////////////////////////////

        // WASD
        if (Input.GetKey(KeyCode.W)) 
        {targetPosition += transform.forward * translationSpeed;}
        if (Input.GetKey(KeyCode.A)) 
        {targetPosition -= transform.right * translationSpeed;}
        if (Input.GetKey(KeyCode.S))         
        {targetPosition -= transform.forward * translationSpeed;}
        if (Input.GetKey(KeyCode.D)) 
        {targetPosition += transform.right * translationSpeed;}

        // SPC CTRL
        if (Input.GetKey(KeyCode.Space)) 
        {targetPosition += transform.up * translationSpeed;}
        if (Input.GetKey(KeyCode.LeftControl)) 
        {targetPosition -= transform.up * translationSpeed;}

Once again, we introduce another constant: translation speed. For me, it is set to 0.6f, but like the other constants, it will vary with application and taste, or perhaps be dependent on game elements (boosted when the “shift” key is depressed, for something like a sprint or afterburner).

LateUpdate: Lerping

Finally, with our desired position and rotation determined, we are ready to interpolate towards them.

Our script will be constantly and always lerping towards the target position and rotation; like the Update() block, LateUpdate() fires every frame.

Both Vector3.Lerp() and Quaternion.Lerp() take three arguments: the start position, the target position, and the interpolation value. They return a vector or quaternion (respectively) at an interpolated distance between the start and target positions, associated with the interpolation value.

That is, with an interpolation value of 0, Lerp() returns the start position; with a value of 1, Lerp() returns the target position; with a value of 0.5, Lerp() returns the midpoint between the start and target positions.

To be explicit, if the start position is a, the target position is b, and the interpolation value is t, Lerp() returns:

a + (b – a) * t

    // Late Update ////////////////////////////////////////////////////////////
    // Lerp toward target position and rotation //////////////////////////////
    void LateUpdate()
    {
        // Lerp toward target position at all times.
        smoothPosition = Vector3.Lerp(transform.position, targetPosition, smoothSpeed);
        transform.position = smoothPosition;

        // Lerp toward target rotation at all times.
        smoothRotation = Quaternion.Lerp(transform.rotation, targetRotation, smoothSpeed);
        transform.rotation = smoothRotation;
    }

We have implicated another constant here – smoothSpeed, the speed at which we converge on our target position and rotation. I have this set to 0.05f, but again, this will vary depending on your application. 0.05 might seem small, but remember, this method is acting every frame.

For the difficult people, I realize there is a case of Zeno’s Dichotomy Paradox here, but rest assured, the camera does indeed stop moving at some point when it gets sufficiently near the target position.

At this point, we have our entire camera control script set up!

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ObserverControl : MonoBehaviour
{
    // GameObjects ////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    public Camera camera;

    // Constants //////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    // Mouse-based camera rotation speed 
    private static float lookSpeed = 5f;

    // Q / E Roll Speed
    private float rollSpeed = 6f;

    // WASD / SPC CTRL Translation Speed
    private float translationSpeed = 0.6f;

    // Lerping Smooth Speed
    private float smoothSpeed = .05f;

    // Variables //////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////

    // The current target position 
    private Vector3 targetPosition;

    // The current intermediary Lerp Position
    private Vector3 smoothPosition;

    // Current Target rotation
    public Quaternion targetRotation;

    // The current intermediary Lerp rotation
    private Quaternion smoothRotation;

    // Start //////////////////////////////////////////////////////////////////
    //////////////////////////////////////////////////////////////////////////
    void Start()
    {
        // Align camera and creator.
        camera.transform.forward = transform.forward;

        // Set target position
        targetPosition = transform.position; 
        targetRotation = transform.rotation;
        Cursor.visible = false;
    }

    // Update /////////////////////////////////////////////////////////////////
    // Detect Maneuvers //////////////////////////////////////////////////////
    void Update()
    {
        // Perform Rotations ////////////////////////////////////////////////
        ////////////////////////////////////////////////////////////////////

        // Rotate Camera based on XY input
        Vector2 delta = Vector2.zero;
        delta.y += Input.GetAxis("Mouse X");
        delta.x -= Input.GetAxis("Mouse Y");
        Quaternion deltaRotation = Quaternion.Euler(delta.x * lookSpeed, delta.y * lookSpeed, 0);

        // Roll camera based on QE input
        if (Input.GetKey(KeyCode.Q))
        {deltaRotation *= Quaternion.Euler(0,0,rollSpeed);}
        if (Input.GetKey(KeyCode.E))
        {deltaRotation *= Quaternion.Euler(0,0,-rollSpeed);}

        // Set target rotation
        targetRotation *= deltaRotation;

        // Perform Translations /////////////////////////////////////////////
        ////////////////////////////////////////////////////////////////////

        // WASD
        if (Input.GetKey(KeyCode.W)) 
        {targetPosition += transform.forward * translationSpeed;}
        if (Input.GetKey(KeyCode.A)) 
        {targetPosition -= transform.right * translationSpeed;}
        if (Input.GetKey(KeyCode.S))         
        {targetPosition -= transform.forward * translationSpeed;}
        if (Input.GetKey(KeyCode.D)) 
        {targetPosition += transform.right * translationSpeed;}

        // SPC CTRL
        if (Input.GetKey(KeyCode.Space)) 
        {targetPosition += transform.up * translationSpeed;}
        if (Input.GetKey(KeyCode.LeftControl)) 
        {targetPosition -= transform.up * translationSpeed;}
    }

    // Late Update ////////////////////////////////////////////////////////////
    // Lerp toward target position and rotation //////////////////////////////
    void LateUpdate()
    {
        // Lerp toward target position at all times.
        smoothPosition = Vector3.Lerp(transform.position, targetPosition, smoothSpeed);
        transform.position = smoothPosition;

        // Lerp toward target rotation at all times.
        smoothRotation = Quaternion.Lerp(transform.rotation, targetRotation, smoothSpeed);
        transform.rotation = smoothRotation;
    }
}

I know it’s not the usual sexy orbits and icospheres, but I struggled enough to get my cameras working correctly that I figured such a post might be useful for other people in the same boat.

Keep in mind this is a free-floating camera. To create a ground-based camera, you will have to establish a clamp on mouse XY rotation and also ensure your translations are parallel with whatever surface the player object is on… more on this in a later post.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s