6.5 Camera Systems

Phase 6 · Gameplay Programming · Study time: 20–35 h

Follow cameras, aiming, collision-aware framing and smoothing — a small system with an outsized impact on how a game feels.

The camera does not move your character, does not deal damage, and does not run any gameplay logic. It just decides what fraction of the game world the player gets to see, and how that view moves. That sounds small, but it is one of the biggest levers you have over how a game feels in the hand. This chapter builds a camera system piece by piece: a follow camera, smoothing, look-ahead, bounds, a third-person orbit camera, wall collision, and screen shake. Along the way it reuses ideas from earlier chapters — frame order, linear interpolation (Lerp), trigonometry, and quaternions.

1. Why the Camera Hugely Affects Feel

Think about two platformers with the exact same level, the exact same physics, and the exact same player controller. In the first, the camera snaps instantly to the player's exact position every frame. In the second, the camera lags a little behind and eases into place. The second one will almost always feel more "alive" and less robotic, even though nothing about the actual gameplay changed.

None of this is about making the camera "correct" — there is usually no single correct camera. It is about tuning a handful of numbers (how fast it follows, how far it looks ahead, how much it shakes) until the game feels right. This chapter gives you the building blocks so you can do that tuning yourself.

Tip When you are not sure why a game you like "feels good", record your screen and watch it in slow motion. Camera behavior that is invisible at normal speed (smoothing, look-ahead, shake) becomes very obvious frame by frame.

The Two Parts of a Unity Camera

Every camera in Unity is a GameObject with two things attached to it that matter for this chapter: a Transform (position, rotation, and scale — the same Transform every GameObject has, and the thing every script in this chapter changes every frame), and a Camera component (settings like field of view, whether the projection is perspective or orthographic, and the near/far clip planes — the closest and farthest distances it will render).

using UnityEngine;

public class CameraInfo : MonoBehaviour
{
    void Start()
    {
        Camera cam = GetComponent<Camera>();
        Debug.Log("Field of view: " + cam.fieldOfView);
        Debug.Log("Orthographic: " + cam.orthographic);
        Debug.Log("Near clip: " + cam.nearClipPlane);
        Debug.Log("Far clip: " + cam.farClipPlane);
    }
}

Attached to the Main Camera with default settings, this prints:

Field of view: 60
Orthographic: False
Near clip: 0.3
Far clip: 1000

fieldOfView (FOV) is the angle of the view cone, in degrees. A small FOV feels like a zoomed-in telephoto lens; a large FOV feels wide-angle and can sell speed. orthographic being false means this is a perspective camera (things farther away look smaller) — the usual choice for 3D games. Orthographic cameras (no perspective shrinking) are common for 2D games, strategy games, and isometric games. nearClipPlane and farClipPlane define the range Unity actually draws — anything closer than 0.3 units or farther than 1000 units from the camera is simply not rendered.

Everything from here on is about one question: what position and rotation should this Transform have, every single frame?

2. The Naive Follow Camera (and Why It Jitters)

The simplest possible follow camera copies the target's position every frame, offset by some fixed distance:

using UnityEngine;

public class SimpleFollowCameraBad : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);

    void Update()
    {
        transform.position = target.position + offset;
    }
}

This works, in the sense that the camera does follow the target. But it is written in Update(), and that causes a subtle bug. Unity does not guarantee the order in which different scripts' Update() methods run within the same frame (unless you explicitly configure script execution order in Project Settings). So on any given frame, the camera's Update() might run before the player's Update() — meaning the camera reads the player's position from before it moved this frame.

// One possible per-frame call order Unity might pick for Update():
Frame 42:
  Camera.Update()  -> reads target.position -> (0.9, 0, 0)   // still last frame's value
  Player.Update()  -> moves the player -> target.position becomes (1.0, 0, 0)
  // The camera already used the OLD position this frame, so it is one step behind.

At a constant frame rate with constant velocity this lag is a fixed, tiny, and mostly invisible offset. The real problem shows up once you add smoothing (section 4) or once the player moves via physics (a Rigidbody, updated in FixedUpdate) — the camera and the target can end up reading each other's positions at inconsistent points in the frame, which shows up as visible jitter or a one-frame "pop" whenever timing shifts.

Common mistake Writing camera-follow code in Update() "because that's where movement code goes." Camera code is a special case — it should almost always read positions after everything else has already moved for the frame.

3. LateUpdate: Follow After Everyone Has Moved

Unity actually runs a fixed pipeline every frame: physics updates first (FixedUpdate, zero or more times), then every script's Update() runs, and only after all of those finish does every script's LateUpdate() run. Then the frame is rendered.

Unity's per-frame pipeline (simplified): FixedUpdate() --> Update() for EVERY script --> LateUpdate() for EVERY script --> Render Bad camera: lives in Update() --> might run before the player's Update() moves it Good camera: lives in LateUpdate() --> guaranteed to run after ALL Update() calls this frame

Because LateUpdate() only runs after every object's Update() has already run, a camera written in LateUpdate() always sees the final, fully-moved position of its target for that frame. No ordering luck involved. Fix the earlier script by changing exactly one word:

using UnityEngine;

public class SimpleFollowCameraGood : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);

    void LateUpdate()
    {
        transform.position = target.position + offset;
    }
}
Frame 42:
  Update() phase     -> Player.Update() moves the player -> target.position becomes (1.0, 0, 0)
                         (every other script's Update() also runs somewhere in this phase)
  LateUpdate() phase -> Camera.LateUpdate() reads target.position -> (1.0, 0, 0)   // fresh and correct

This is the single most important rule in this chapter: anything that follows, orbits, or otherwise reacts to another object's position belongs in LateUpdate(), not Update(). Every script for the rest of this chapter uses LateUpdate().

Tip If your target moves with a Rigidbody inside FixedUpdate, also check its Interpolate setting in the Rigidbody component. That setting smooths the visual position between physics steps — it works together with, not instead of, camera smoothing, and connects to the interpolation ideas from the earlier interpolation chapter.

4. Smoothing the Follow: Lerp, Framerate Independence, and SmoothDamp

A camera that snaps exactly onto its target every frame feels rigid. Games almost always let the camera trail slightly behind and ease into place — the same linear interpolation (Lerp) idea from the interpolation chapter, applied to the camera every single frame.

The Naive Version — and Its Framerate Bug

using UnityEngine;

public class FollowCameraNaiveLerp : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothFactor = 0.1f; // fraction of the remaining distance closed EACH CALL

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothFactor);
    }
}

This closes 10% of the remaining distance every time LateUpdate() runs. The bug: LateUpdate() runs once per rendered frame, and the number of frames per second depends on the player's hardware. A fixed per-call factor means the camera closes the gap at very different real-world speeds depending on frame rate:

// remaining fraction of the distance left after 1 real second, factor = 0.1:
// 30 FPS  (30 calls/second):  0.9^30  => about 4.2% of the distance still remains
// 60 FPS  (60 calls/second):  0.9^60  => about 0.18% of the distance still remains
// 144 FPS (144 calls/second): 0.9^144 => about 0.00009% -- visually already snapped

A player on a 144Hz monitor gets a camera that basically snaps instantly; a player on a 30 FPS console gets a noticeably laggy camera. Same code, wildly different feel — purely because of frame rate.

Common mistake Writing Vector3.Lerp(transform.position, desiredPosition, speed * Time.deltaTime) and assuming that fixes framerate independence. It is an improvement, but it is only an approximation — at low frame rates speed * Time.deltaTime can exceed 1, which overshoots the target and can even oscillate. Use the exact formula below, or SmoothDamp, instead.

The Fix: Exponential Smoothing

using UnityEngine;

public class FollowCameraSmoothed : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f; // higher = snappier, in "closures per second"

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

This uses Mathf.Exp (the exponential function) to turn smoothRate and the actual elapsed time (Time.deltaTime) into a Lerp factor t. Because it is based on elapsed real time instead of "how many times did this run", the total distance closed after any fixed amount of real time is almost exactly the same no matter the frame rate:

// remaining fraction after 0.5 real seconds, smoothRate = 8:
// computed in 15 steps at 30 FPS, or 30 steps at 60 FPS, or 72 steps at 144 FPS --
// all three land within a fraction of a percent of exp(-8 * 0.5) => about 1.8% remaining

Same feel, any hardware. This is the general pattern: never bake a raw "amount per frame" into a Lerp factor — convert your desired "amount per second" into a per-frame factor using Time.deltaTime, ideally through Mathf.Exp like above.

The Built-In Version: SmoothDamp

Unity already ships a function that does this for you, and behaves like a critically damped spring (it eases in and slows down as it arrives, with no overshoot by default):

using UnityEngine;

public class FollowCameraSmoothDamp : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothTime = 0.25f; // roughly, seconds to close most of the distance
    Vector3 velocity; // SmoothDamp needs a place to remember velocity between calls

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.SmoothDamp(transform.position, desiredPosition, ref velocity, smoothTime);
    }
}

Vector3.SmoothDamp is already framerate-independent internally (it reads Time.deltaTime itself). The ref velocity parameter is a variable that SmoothDamp reads and writes every call to track how fast the camera is currently moving — you declare it once as a field and just keep passing it in; you never set it yourself. In practice, SmoothDamp is what most hand-written Unity follow cameras actually use.

Target moves right. The camera trails and gradually catches up (C = camera, T = target): frame 0: C-------------------T frame 1: C---------------T frame 2: C-----------T frame 3: C-------T frame 4: C---T frame 5: CT (camera has essentially caught up) That trailing gap is not a bug -- it is exactly what makes the motion read as smooth instead of the camera instantly teleporting onto the target every single frame.

5. Look-Ahead: Showing What's Coming

A pure follow camera always centers the target. At low speed that is fine, but at high speed the player usually wants to see more of what is ahead of them than what is behind — so the camera should lean slightly in the direction of movement.

using UnityEngine;

public class FollowCameraLookAhead : MonoBehaviour
{
    public Transform target;
    public Rigidbody targetBody;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public float lookAheadDistance = 3f;
    public float lookAheadSmoothRate = 4f;

    Vector3 currentLookAhead;

    void LateUpdate()
    {
        Vector3 desiredLookAhead = Vector3.zero;
        if (targetBody.velocity.sqrMagnitude > 0.01f)
        {
            desiredLookAhead = targetBody.velocity.normalized * lookAheadDistance;
        }

        // smooth the look-ahead offset itself, separately from the camera position
        float lookT = 1f - Mathf.Exp(-lookAheadSmoothRate * Time.deltaTime);
        currentLookAhead = Vector3.Lerp(currentLookAhead, desiredLookAhead, lookT);

        Vector3 desiredPosition = target.position + offset + currentLookAhead;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

Notice currentLookAhead is smoothed on its own, separately from the camera's own position smoothing. Here is why that matters — a worked trace of the player suddenly reversing direction:

// player was running right, instantly stops and runs left. lookAheadDistance = 3
// WITHOUT its own smoothing (using desiredLookAhead directly every frame):
frame 0: desiredLookAhead.x = +3.0   (running right)
frame 1: desiredLookAhead.x = -3.0   (running left) -- camera would JUMP 6 units in one frame

// WITH its own smoothing (currentLookAhead easing toward desiredLookAhead):
frame 0: currentLookAhead.x = 2.6
frame 1: currentLookAhead.x = 1.4   -- easing back through zero
frame 2: currentLookAhead.x = 0.1
frame 3: currentLookAhead.x = -1.0  -- easing out to the other side, no sudden jump

Without the extra smoothing step, every direction change would whip the camera instantly — exactly the jittery feeling this whole chapter is trying to avoid.

Standing still, camera centers the target: Running right, camera leans ahead: [Cam] [Cam] | | v v (target) (target) ---look-ahead---> (more open space here)

6. Clamping the Camera to Level Bounds

In a level with a hard edge (a side-scroller's start/end, a top-down arena's walls), you usually do not want the camera to ever show the empty space beyond that edge. The fix is simple: clamp the desired camera position into a box before smoothing toward it.

using UnityEngine;

public class FollowCameraClamped : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public Vector2 minBounds = new Vector2(-20f, -5f);
    public Vector2 maxBounds = new Vector2(20f, 15f);

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;

        desiredPosition.x = Mathf.Clamp(desiredPosition.x, minBounds.x, maxBounds.x);
        desiredPosition.y = Mathf.Clamp(desiredPosition.y, minBounds.y, maxBounds.y);

        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

Worked trace, with maxBounds.x = 20, as the target walks toward the right edge of the level:

// target.x = 18, offset.x = 0  -> desiredPosition.x = 18   (18 <= 20, unclamped)
// target.x = 22, offset.x = 0  -> desiredPosition.x = 22   (22 > 20)
//                                  Mathf.Clamp(22, -20, 20) => 20   -- held at the edge
// target.x = 30, offset.x = 0  -> desiredPosition.x = 30   (30 > 20)
//                                  Mathf.Clamp(30, -20, 20) => 20   -- still held at the edge

Even though the target keeps walking past x = 20, the camera's desired position never goes past the clamp — the player just walks toward the edge of the screen instead of the camera continuing to follow into empty space.

Common mistake Clamping only the camera's center point and forgetting that the camera actually shows a whole rectangle (or frustum) around that center. If your bounds are exactly the level's edges, the camera can still show empty space just past the edge. Shrink the clamp box inward by roughly half the camera's visible width/height (for an orthographic camera, half of orthographicSize times the aspect ratio) to fully hide the edge.

7. Third-Person Orbit Camera: Yaw, Pitch, and Quaternions

A follow camera keeps a fixed offset. A third-person orbit camera instead lets the player rotate around the target with the mouse — think of any modern action or RPG game. This is where trigonometry and quaternions from earlier chapters come back directly.

The idea: track two angles, yaw (rotation around the world's up axis — turning left/right) and pitch (rotation around a local sideways axis — tilting up/down). Combine them into one rotation, then place the camera behind the target along that rotation's forward direction.

using UnityEngine;

public class OrbitCamera : MonoBehaviour
{
    public Transform target;
    public float distance = 5f;
    public float yawSpeed = 120f;
    public float pitchSpeed = 80f;
    public float minPitch = -20f;
    public float maxPitch = 60f;

    float yaw;
    float pitch = 15f;

    void LateUpdate()
    {
        yaw += Input.GetAxis("Mouse X") * yawSpeed * Time.deltaTime;
        pitch -= Input.GetAxis("Mouse Y") * pitchSpeed * Time.deltaTime;
        pitch = Mathf.Clamp(pitch, minPitch, maxPitch);

        Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
        Vector3 desiredPosition = target.position - (rotation * Vector3.forward * distance);

        transform.position = desiredPosition;
        transform.rotation = rotation;
    }
}

Quaternion.Euler(pitch, yaw, 0f) builds one rotation out of the two angles (this is exactly the Euler-angles-to-quaternion conversion from the quaternion chapter). rotation * Vector3.forward takes the world "forward" direction and rotates it by that quaternion — the result is a unit vector pointing wherever the camera is currently looking. Multiplying that by distance and subtracting from the target's position places the camera exactly distance units behind the direction the camera faces.

// yaw = 0, pitch = 0: rotation * Vector3.forward = (0, 0, 1)
//   desiredPosition = target.position - (0, 0, 1) * 5 = target.position + (0, 0, -5)
//   -- camera sits directly behind the target on the -Z side, facing +Z (toward the target)

// yaw = 90, pitch = 0: rotation * Vector3.forward = (1, 0, 0)   (rotated 90 deg around Y)
//   desiredPosition = target.position - (1, 0, 0) * 5 = target.position + (-5, 0, 0)
//   -- camera has swung around to the target's left side
Top-down view (yaw = rotation around the target's up axis): yaw=90 | yaw=180 --(target)-- yaw=0 | yaw=270 Side view (pitch = rotation around the local sideways axis, clamped so it cannot flip over): camera (pitch = maxPitch, looking down) / / ------(target)------ / / camera (pitch = minPitch, looking up)
Tip Clamping pitch to a sane range (here, -20 to 60 degrees) stops the camera from swinging all the way over the target and flipping upside down, which is disorienting. This is why the code tracks yaw and pitch as two separate floats instead of accumulating rotation directly on the Transform — separate floats are easy to clamp; an accumulated quaternion is not.

8. Collision-Aware Camera: Don't Clip Through Walls

The orbit camera above has one serious problem: if a wall gets between the target and the camera's desired position, the camera will happily sit inside or behind the wall, and the player will either see through the wall or see nothing at all. The fix is to cast a ray from the target toward the desired camera position, and if it hits something, pull the camera in to just before the hit point.

using UnityEngine;

public class OrbitCameraWithCollision : MonoBehaviour
{
    public Transform target;
    public float distance = 5f;
    public float yawSpeed = 120f;
    public float pitchSpeed = 80f;
    public float minPitch = -20f;
    public float maxPitch = 60f;
    public float collisionBuffer = 0.3f;
    public LayerMask collisionMask;

    float yaw;
    float pitch = 15f;

    void LateUpdate()
    {
        yaw += Input.GetAxis("Mouse X") * yawSpeed * Time.deltaTime;
        pitch -= Input.GetAxis("Mouse Y") * pitchSpeed * Time.deltaTime;
        pitch = Mathf.Clamp(pitch, minPitch, maxPitch);

        Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
        Vector3 camDirection = rotation * Vector3.forward * -1f; // direction FROM target TO camera
        float desiredDistance = distance;

        RaycastHit hit;
        if (Physics.Raycast(target.position, camDirection, out hit, distance, collisionMask))
        {
            desiredDistance = Mathf.Max(hit.distance - collisionBuffer, 0.2f);
        }

        transform.position = target.position + camDirection * desiredDistance;
        transform.rotation = rotation;
    }
}

camDirection points from the target toward where the camera wants to be. Physics.Raycast fires a ray of length distance in that direction and reports the closest thing it hits, through the hit output parameter. If nothing is hit, the camera uses the full distance. If something is hit, the camera is placed at hit.distance (how far along the ray it hit) minus a small collisionBuffer, so the camera sits just in front of the wall instead of touching it.

Clear line of sight -- camera sits at the full desired distance: (target) ---------------------------> [camera] Wall in the way -- the ray from target toward the desired spot hits it first: (target) --------|WALL| [camera desired position, but that spot is blocked] ray hits the wall here Camera gets pulled in to just before the hit point: (target) --------[camera]|WALL|

Worked trace, distance = 5, collisionBuffer = 0.3:

// no wall within 5 units: hit is not found -> desiredDistance stays 5.0
// wall found at hit.distance = 2.0:
//   desiredDistance = Mathf.Max(2.0 - 0.3, 0.2) = 1.7   -- camera sits 1.7 units from the target
// wall found extremely close, hit.distance = 0.1:
//   desiredDistance = Mathf.Max(0.1 - 0.3, 0.2) = Mathf.Max(-0.2, 0.2) = 0.2  -- floor prevents going negative
Common mistake Using Physics.Raycast (an infinitely thin line) for camera collision. A thin ray can slip through the gap next to a thin wall corner that the camera's actual lens (which has some width) would still clip into. Physics.SphereCast (a ray with thickness) is usually a better fit for cameras — same idea, just replace Raycast with SphereCast and pass a small radius.

9. Screen Shake and Combining Systems

Screen shake sells impact — an explosion, a heavy landing, taking damage. The core idea is simple: for a short time, add a small random offset to the camera's position every frame, and shrink that offset over time until it reaches zero.

A Decaying Random Offset

using UnityEngine;

public class ScreenShake : MonoBehaviour
{
    float shakeDuration = 0f;
    float shakeMagnitude = 0f;
    Vector3 initialPosition;

    void OnEnable()
    {
        initialPosition = transform.localPosition;
    }

    public void Shake(float duration, float magnitude)
    {
        shakeDuration = duration;
        shakeMagnitude = magnitude;
    }

    void Update()
    {
        if (shakeDuration > 0f)
        {
            transform.localPosition = initialPosition + Random.insideUnitSphere * shakeMagnitude;
            shakeDuration -= Time.deltaTime;
        }
        else
        {
            transform.localPosition = initialPosition;
        }
    }
}

Random.insideUnitSphere returns a random point inside a sphere of radius 1 — multiplying it by shakeMagnitude scales that random offset up or down. shakeDuration counts down every frame with Time.deltaTime (framerate-independent, same idea as earlier sections), and once it reaches zero the camera snaps back to initialPosition exactly.

// Shake(duration: 0.3, magnitude: 0.5) called on impact, at 60 FPS (dt about 0.0167):
// frame 0: shakeDuration = 0.300 -> still shaking, offset randomly within 0.5 units
// frame 6: shakeDuration = 0.200 -> still shaking
// frame 12: shakeDuration = 0.100 -> still shaking
// frame 18: shakeDuration = 0.001 -> still shaking (barely)
// frame 19: shakeDuration = -0.015 -> else branch runs, position snaps back to initialPosition

This version shakes at a constant magnitude right up until it abruptly stops — good enough for a quick effect, but a bit sudden. Exercise 3 at the end of this chapter asks you to make the magnitude fade out smoothly instead of cutting off.

Tip A single hard random jump per frame (as above) can look "buzzy" or harsh. Professional implementations often sample Perlin noise (a smooth pseudo-random function, different from pure Random) over time instead, which produces a softer, more organic-looking shake. The decay logic stays exactly the same — only the source of randomness changes.

Putting It Together: A Camera Rig with a Shake Child

If you attach both a follow script and the ScreenShake script to the same GameObject, they will fight: both write to the same transform.position every frame, and whichever one runs last wins, silently breaking the other. The clean fix is a small hierarchy that separates "where the camera should logically be" from "extra jitter on top of that."

CameraRig (empty GameObject) <-- FollowCamera / OrbitCamera script moves THIS transform (world position) | +-- Camera (GameObject with the Camera component) <-- ScreenShake script moves only THIS localPosition

ScreenShake already writes to transform.localPosition, not transform.position — that was intentional. Local position is relative to the parent, so as long as the shake script lives on a child of the rig, its small random jitter is added on top of wherever the parent rig currently is, instead of overwriting it. The rig moves however you like (follow, orbit, with bounds and collision); the child just wiggles a little around wherever the rig puts it.

Triggering a shake from gameplay code then looks like this:

public class Explosion : MonoBehaviour
{
    public ScreenShake cameraShake;

    void OnExplode()
    {
        cameraShake.Shake(0.3f, 0.5f);
    }
}

cameraShake here is a reference to the ScreenShake component on the child Camera object, dragged in through the Inspector (or found once with GetComponentInChildren). Any gameplay system — taking damage, landing hard, firing a big weapon — just calls Shake(duration, magnitude) without needing to know anything about how the camera is currently moving.

10. Cinemachine and Debugging

Cinemachine: Unity's Built-In Camera Toolkit

Everything in this chapter was written by hand so you understand exactly what is happening and why. In real production, most Unity teams instead use Cinemachine, a free official Unity package that implements the same ideas (and a lot more) without you writing the follow/smoothing/collision code yourself.

Knowing how to build these systems by hand still matters — Cinemachine cannot cover every custom camera idea, and understanding what it is doing under the hood makes you far more effective at tuning it (or debugging it when it does something you did not expect). But for most day-to-day production work, reach for Cinemachine first, and drop down to hand-written camera code for the specific behaviors it does not cover.

Debugging and Common Pitfalls

Whether you build cameras by hand or lean on Cinemachine, you will eventually need to debug one that is not behaving. A cheap way to actually see what it is doing:

void OnDrawGizmos()
{
    if (target == null) return;
    Gizmos.color = Color.yellow;
    Gizmos.DrawLine(target.position, transform.position);
    Gizmos.DrawWireSphere(transform.position, 0.3f);
}

OnDrawGizmos runs only in the editor's Scene view, never in a build, and never prints anything to the console — instead, with this script attached, the Scene view shows a yellow line from the target straight to the camera every frame, plus a small wireframe sphere at the camera's exact position. Watching that line and sphere while you play makes it immediately obvious if the camera is lagging, clamped somewhere unexpected, or has drifted off from the rig.

That covers the full pipeline: read the target, smooth toward it, lead it, keep it in bounds, let the player orbit it, keep walls out of the way, and shake it on impact. Every one of those is just a few lines of math running once a frame in LateUpdate() — the "feel" of a camera comes from tuning the numbers, not from complicated code.

11. Glossary

12. Exercises

Exercise 1 The script below is meant to be a smooth follow camera, but players report it stutters badly and feels different on different computers. Find the bugs (there are two) and rewrite it correctly.
using UnityEngine;

public class BuggyFollowCamera : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothFactor = 0.1f;

    void Update()
    {
        Vector3 desiredPosition = target.position + offset;
        transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothFactor);
    }
}
Show answer

Bug 1: the code runs in Update(), so it can read the target's position before the target's own Update() has moved it this frame, causing jitter. Fix: move the logic to LateUpdate().

Bug 2: smoothFactor is used directly as the Lerp fraction every call, with no connection to elapsed time — so the camera closes the gap at a rate that depends on frame rate (fast on a high-FPS machine, slow on a low-FPS one). Fix: convert it into a framerate-independent factor using Time.deltaTime and Mathf.Exp.

using UnityEngine;

public class FixedFollowCamera : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;

    void LateUpdate()
    {
        Vector3 desiredPosition = target.position + offset;
        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

Now the camera always reads the target's final position for the frame, and closes the same fraction of distance per real second no matter the frame rate.

Exercise 2 Start from the framerate-independent follow camera from section 4 (FollowCameraSmoothed). Add two features to it: clamp the final position inside minBounds/maxBounds (as in section 6), AND add a simple look-ahead based on horizontal keyboard input — when the player holds right, the camera should lean right; when they hold left, it should lean left. You do not have a Rigidbody to read velocity from this time, so build the look-ahead directly from Input.GetAxis("Horizontal") instead. Write the full script.
Show answer
using UnityEngine;

public class FollowCameraClampedLookAhead : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -8f);
    public float smoothRate = 8f;
    public Vector2 minBounds = new Vector2(-20f, -5f);
    public Vector2 maxBounds = new Vector2(20f, 15f);
    public float lookAheadDistance = 3f;
    public float lookAheadSmoothRate = 4f;

    Vector3 currentLookAhead;

    void LateUpdate()
    {
        float horizontal = Input.GetAxis("Horizontal");
        Vector3 desiredLookAhead = new Vector3(horizontal, 0f, 0f) * lookAheadDistance;

        float lookT = 1f - Mathf.Exp(-lookAheadSmoothRate * Time.deltaTime);
        currentLookAhead = Vector3.Lerp(currentLookAhead, desiredLookAhead, lookT);

        Vector3 desiredPosition = target.position + offset + currentLookAhead;
        desiredPosition.x = Mathf.Clamp(desiredPosition.x, minBounds.x, maxBounds.x);
        desiredPosition.y = Mathf.Clamp(desiredPosition.y, minBounds.y, maxBounds.y);

        float t = 1f - Mathf.Exp(-smoothRate * Time.deltaTime);
        transform.position = Vector3.Lerp(transform.position, desiredPosition, t);
    }
}

Note the order: look-ahead is added to the desired position before clamping, so the clamp is the final authority — the camera can never show past the level edge, even while leaning ahead near a wall. Clamping happens before the final smoothing step, same as section 6.

Exercise 3 The ScreenShake script from section 9 shakes at a constant magnitude and then stops abruptly the instant shakeDuration reaches zero, which can look sudden. Modify it so the magnitude fades out smoothly across the duration — strong at the start, fading to nothing by the end — instead of cutting off. Reuse the same Shake(duration, magnitude) public method signature.
Show answer
using UnityEngine;

public class ScreenShakeFading : MonoBehaviour
{
    float shakeTimer = 0f;
    float shakeDuration = 0f;
    float startMagnitude = 0f;
    Vector3 initialPosition;

    void OnEnable()
    {
        initialPosition = transform.localPosition;
    }

    public void Shake(float duration, float magnitude)
    {
        shakeDuration = duration;
        shakeTimer = duration;
        startMagnitude = magnitude;
    }

    void Update()
    {
        if (shakeTimer > 0f)
        {
            float t = shakeTimer / shakeDuration;          // 1 at the start, 0 at the end
            float currentMagnitude = startMagnitude * t;    // fades out linearly

            transform.localPosition = initialPosition + Random.insideUnitSphere * currentMagnitude;
            shakeTimer -= Time.deltaTime;
        }
        else
        {
            transform.localPosition = initialPosition;
        }
    }
}

The key change: a separate shakeTimer counts down from the original shakeDuration, and their ratio t goes from 1.0 down to 0.0 over the shake. Multiplying startMagnitude by that ratio each frame makes the random offset shrink smoothly instead of staying constant and then snapping off. Swapping the linear t for an AnimationCurve evaluated at t would let a designer tune the falloff shape without touching code.

← Back to all chapters