Third Person Camera Pro
Advanced orbit camera with lock-on targeting, shoulder swap, cinematic mode, and swappable camera profiles for versatile third person gameplay.
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
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
}