Need something simpler? See the (basic version).
intermediate Movement PRO

Third Person Camera Pro

Advanced orbit camera with lock-on targeting, shoulder swap, cinematic mode, and swappable camera profiles for versatile third person gameplay.

Unity 2022.3+ · 6.5 KB · ThirdPersonCameraPro.cs

How to Use

1

Attach to a CameraRig empty object, parent your Camera to it

2

Assign your player Transform as target

3

Configure default camera profile (distance, height, FOV)

4

Press V to swap camera shoulder (left/right)

5

Middle-click to lock onto nearest enemy, Tab to cycle targets

6

Set Lock-On Layer to your enemy layer

7

Create additional CameraProfile entries for combat/exploration modes

8

Call SetProfile('ProfileName') to switch at runtime

9

Call EnterCinematicMode() for cutscenes

Source Code

ThirdPersonCameraPro.cs
C#
using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;

public class ThirdPersonCameraPro : MonoBehaviour
{
    [System.Serializable]
    public class CameraProfile
    {
        public string profileName = "Default";
        public float distance = 5f;
        public float height = 2f;
        public float minVerticalAngle = -30f;
        public float maxVerticalAngle = 60f;
        public Vector3 shoulderOffset = new Vector3(0.6f, 0f, 0f);
        public float fieldOfView = 60f;
        public float rotationSpeed = 3f;
    }

    [Header("Target")]
    [SerializeField] private Transform target;
    [SerializeField] private Vector3 targetOffset = new Vector3(0f, 1.5f, 0f);

    [Header("Default Profile")]
    [SerializeField] private CameraProfile defaultProfile = new CameraProfile();

    [Header("Collision")]
    [SerializeField] private float collisionRadius = 0.25f;
    [SerializeField] private LayerMask collisionLayers = ~0;

    [Header("Zoom")]
    [SerializeField] private float minDistance = 2f;
    [SerializeField] private float maxDistance = 10f;
    [SerializeField] private float zoomSpeed = 3f;
    [SerializeField] private float zoomSmoothing = 8f;

    [Header("Lock-On")]
    [SerializeField] private bool enableLockOn = true;
    [SerializeField] private float lockOnRange = 25f;
    [SerializeField] private float lockOnAngle = 60f;
    [SerializeField] private LayerMask lockOnLayer;
    [SerializeField] private KeyCode lockOnKey = KeyCode.Mouse2;
    [SerializeField] private KeyCode cycleTargetKey = KeyCode.Tab;
    [SerializeField] private float lockOnLerp = 5f;

    [Header("Shoulder Swap")]
    [SerializeField] private KeyCode shoulderSwapKey = KeyCode.V;
    [SerializeField] private float shoulderSwapSpeed = 6f;

    [Header("Cinematic")]
    [SerializeField] private CameraProfile cinematicProfile;
    [SerializeField] private float cinematicTransitionSpeed = 3f;

    [Header("Profiles")]
    [SerializeField] private CameraProfile[] additionalProfiles;

    [Header("Events")]
    public UnityEvent<Transform> OnLockOnTarget;
    public UnityEvent OnLockOnLost;

    private Camera cam;
    private CameraProfile activeProfile;
    private float currentX;
    private float currentY;
    private float currentDistance;
    private float targetDistance;
    private float shoulderSide = 1f;
    private float currentShoulderSide = 1f;

    // Lock-on
    private bool isLockedOn;
    private Transform lockOnTarget;
    private List<Transform> potentialTargets = new List<Transform>();

    // Cinematic
    private bool isCinematic;
    private CameraProfile transitionFrom;
    private float profileTransition = 1f;

    private void Start()
    {
        cam = GetComponent<Camera>();
        if (cam == null) cam = GetComponentInChildren<Camera>();
        activeProfile = defaultProfile;
        targetDistance = defaultProfile.distance;
        currentDistance = targetDistance;
    }

    private void Update()
    {
        if (target == null) return;

        HandleInput();
        HandleLockOnInput();

        if (!isCinematic && !isLockedOn)
        {
            currentX += Input.GetAxis("Mouse X") * GetEffectiveProfile().rotationSpeed;
            currentY -= Input.GetAxis("Mouse Y") * GetEffectiveProfile().rotationSpeed;
            currentY = Mathf.Clamp(currentY, GetEffectiveProfile().minVerticalAngle,
                GetEffectiveProfile().maxVerticalAngle);
        }

        float scroll = Input.GetAxis("Mouse ScrollWheel");
        if (Mathf.Abs(scroll) > 0.01f)
        {
            targetDistance -= scroll * zoomSpeed;
            targetDistance = Mathf.Clamp(targetDistance, minDistance, maxDistance);
        }
    }

    private void LateUpdate()
    {
        if (target == null) return;

        CameraProfile profile = GetEffectiveProfile();

        // Smooth distance
        currentDistance = Mathf.Lerp(currentDistance, targetDistance, zoomSmoothing * Time.deltaTime);

        // Lock-on look direction
        if (isLockedOn && lockOnTarget != null)
        {
            Vector3 midPoint = (target.position + lockOnTarget.position) * 0.5f;
            Vector3 lookDir = midPoint - transform.position;
            Quaternion targetRot = Quaternion.LookRotation(lookDir);
            Vector3 euler = targetRot.eulerAngles;
            currentX = Mathf.LerpAngle(currentX, euler.y, lockOnLerp * Time.deltaTime);
            currentY = Mathf.LerpAngle(currentY, euler.x > 180f ? euler.x - 360f : euler.x,
                lockOnLerp * Time.deltaTime);
        }

        // Calculate position
        Vector3 focusPoint = target.position + targetOffset;
        Quaternion rotation = Quaternion.Euler(currentY, currentX, 0f);

        // Shoulder offset
        currentShoulderSide = Mathf.Lerp(currentShoulderSide, shoulderSide,
            shoulderSwapSpeed * Time.deltaTime);
        Vector3 shoulder = profile.shoulderOffset;
        shoulder.x *= currentShoulderSide;

        Vector3 desiredPosition = focusPoint + rotation * (shoulder + new Vector3(0f,
            profile.height - targetOffset.y, -currentDistance));

        // Collision
        Vector3 direction = desiredPosition - focusPoint;
        float adjustedDistance = direction.magnitude;

        if (Physics.SphereCast(focusPoint, collisionRadius, direction.normalized,
            out RaycastHit hit, adjustedDistance, collisionLayers))
        {
            float safeDist = hit.distance - collisionRadius;
            safeDist = Mathf.Max(safeDist, 0.5f);
            desiredPosition = focusPoint + direction.normalized * safeDist;
        }

        transform.position = desiredPosition;
        transform.LookAt(focusPoint);

        // Profile transition
        if (profileTransition < 1f)
        {
            profileTransition = Mathf.MoveTowards(profileTransition, 1f,
                cinematicTransitionSpeed * Time.deltaTime);
        }

        // FOV
        if (cam != null)
        {
            float targetFOV = profile.fieldOfView;
            cam.fieldOfView = Mathf.Lerp(cam.fieldOfView, targetFOV, 5f * Time.deltaTime);
        }
    }

    private void HandleInput()
    {
        if (Input.GetKeyDown(shoulderSwapKey))
            shoulderSide = -shoulderSide;
    }

    private void HandleLockOnInput()
    {
        if (!enableLockOn) return;

        if (Input.GetKeyDown(lockOnKey))
        {
            if (isLockedOn) DisengageLockOn();
            else EngageLockOn();
        }

        if (isLockedOn && Input.GetKeyDown(cycleTargetKey))
            CycleTarget();

        // Validate lock-on target
        if (isLockedOn && lockOnTarget != null)
        {
            float dist = Vector3.Distance(target.position, lockOnTarget.position);
            if (dist > lockOnRange * 1.2f || !lockOnTarget.gameObject.activeInHierarchy)
                DisengageLockOn();
        }
    }

    private void EngageLockOn()
    {
        FindPotentialTargets();
        if (potentialTargets.Count == 0) return;

        // Pick closest to screen center
        Transform best = null;
        float bestScore = float.MaxValue;

        foreach (var t in potentialTargets)
        {
            Vector3 viewPos = cam != null ? cam.WorldToViewportPoint(t.position) : Vector3.zero;
            float screenDist = Vector2.Distance(new Vector2(viewPos.x, viewPos.y), Vector2.one * 0.5f);
            float worldDist = Vector3.Distance(target.position, t.position);
            float score = screenDist * 10f + worldDist;

            if (score < bestScore)
            {
                bestScore = score;
                best = t;
            }
        }

        if (best != null)
        {
            lockOnTarget = best;
            isLockedOn = true;
            OnLockOnTarget?.Invoke(lockOnTarget);
        }
    }

    private void DisengageLockOn()
    {
        isLockedOn = false;
        lockOnTarget = null;
        OnLockOnLost?.Invoke();
    }

    private void CycleTarget()
    {
        FindPotentialTargets();
        if (potentialTargets.Count <= 1) return;

        int currentIndex = potentialTargets.IndexOf(lockOnTarget);
        if (currentIndex < 0) currentIndex = 0;
        int next = (currentIndex + 1) % potentialTargets.Count;
        lockOnTarget = potentialTargets[next];
        OnLockOnTarget?.Invoke(lockOnTarget);
    }

    private void FindPotentialTargets()
    {
        potentialTargets.Clear();
        Collider[] colliders = Physics.OverlapSphere(target.position, lockOnRange, lockOnLayer);

        foreach (var col in colliders)
        {
            if (col.transform == target) continue;

            Vector3 dir = col.transform.position - target.position;
            float angle = Vector3.Angle(target.forward, dir);

            if (angle <= lockOnAngle)
                potentialTargets.Add(col.transform);
        }
    }

    /// <summary>Enter cinematic camera mode.</summary>
    public void EnterCinematicMode()
    {
        if (cinematicProfile == null) return;
        isCinematic = true;
        transitionFrom = activeProfile;
        profileTransition = 0f;
        activeProfile = cinematicProfile;
    }

    /// <summary>Exit cinematic mode.</summary>
    public void ExitCinematicMode()
    {
        isCinematic = false;
        profileTransition = 1f;
        activeProfile = defaultProfile;
    }

    /// <summary>Switch to a named camera profile.</summary>
    public void SetProfile(string profileName)
    {
        if (additionalProfiles == null) return;
        foreach (var p in additionalProfiles)
        {
            if (p.profileName == profileName)
            {
                activeProfile = p;
                targetDistance = p.distance;
                return;
            }
        }
    }

    /// <summary>Reset to default profile.</summary>
    public void ResetProfile()
    {
        activeProfile = defaultProfile;
        targetDistance = defaultProfile.distance;
    }

    /// <summary>Whether lock-on is currently active.</summary>
    public bool IsLockedOn => isLockedOn;

    /// <summary>Current lock-on target, or null.</summary>
    public Transform LockOnTarget => lockOnTarget;

    private CameraProfile GetEffectiveProfile() => activeProfile ?? defaultProfile;

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (target == null) return;

        // Lock-on range
        if (enableLockOn)
        {
            Gizmos.color = new Color(1f, 0.5f, 0f, 0.15f);
            Gizmos.DrawWireSphere(target.position, lockOnRange);
        }

        // Lock-on target
        if (lockOnTarget != null)
        {
            Gizmos.color = Color.red;
            Gizmos.DrawLine(target.position + Vector3.up, lockOnTarget.position + Vector3.up);
            Gizmos.DrawWireSphere(lockOnTarget.position, 0.5f);
        }
    }
#endif
}