using UnityEngine;
using System.Collections.Generic;
using System;
using Unity.Cinemachine.TargetTracking;
namespace Unity.Cinemachine
{
///
/// This is a CinemachineComponent in the the Body section of the component pipeline.
/// Its job is to position the camera somewhere on a spheroid centered at the Follow target.
///
/// The position on the sphere and the radius of the sphere can be controlled by user input.
///
[AddComponentMenu("Cinemachine/Procedural/Position Control/Cinemachine Orbital Follow")]
[SaveDuringPlay]
[DisallowMultipleComponent]
[CameraPipeline(CinemachineCore.Stage.Body)]
[RequiredTarget(RequiredTargetAttribute.RequiredTargets.Tracking)]
[HelpURL(Documentation.BaseURL + "manual/CinemachineOrbitalFollow.html")]
public class CinemachineOrbitalFollow
: CinemachineComponentBase, IInputAxisOwner, IInputAxisResetSource
, CinemachineFreeLookModifier.IModifierValueSource
, CinemachineFreeLookModifier.IModifiablePositionDamping
, CinemachineFreeLookModifier.IModifiableDistance
{
/// Offset from the object's center in local space.
/// Use this to fine-tune the orbit when the desired focus of the orbit is not
/// the tracked object's center
[Tooltip("Offset from the target object's center in target-local space. Use this to fine-tune the "
+ "orbit when the desired focus of the orbit is not the tracked object's center.")]
public Vector3 TargetOffset;
/// Settings to control damping for target tracking.
public TrackerSettings TrackerSettings = TrackerSettings.Default;
/// How to construct the surface on which the camera will travel
public enum OrbitStyles
{
/// Camera is at a fixed distance from the target,
/// defining a sphere
Sphere,
/// Camera surface is built by extruding a line connecting 3 circular
/// orbits around the target
ThreeRing
}
/// How to construct the surface on which the camera will travel
[Tooltip("Defines the manner in which the orbit surface is constructed." )]
public OrbitStyles OrbitStyle = OrbitStyles.Sphere;
/// The camera will be placed at this distance from the Follow target
[Tooltip("The camera will be placed at this distance from the Follow target.")]
public float Radius = 10;
/// Defines a complex surface rig from 3 horizontal rings.
[Tooltip("Defines a complex surface rig from 3 horizontal rings.")]
[HideFoldout]
public Cinemachine3OrbitRig.Settings Orbits = Cinemachine3OrbitRig.Settings.Default;
/// Defines the reference frame in which horizontal recentering is done.
public enum ReferenceFrames
{
/// Static reference frame. Axis center value is not dynamically updated.
AxisCenter,
/// Axis center is dynamically adjusted to be behind the parent
/// object's forward.
ParentObject,
/// Axis center is dynamically adjusted to be behind the
/// Tracking Target's forward.
TrackingTarget,
/// Axis center is dynamically adjusted to be behind the
/// LookAt Target's forward.
LookAtTarget
}
/// Defines the reference frame for horizontal recentering. The axis center
/// will be dynamically updated to be behind the selected object.
[Tooltip("Defines the reference frame for horizontal recentering. The axis center "
+ "will be dynamically updated to be behind the selected object.")]
public ReferenceFrames RecenteringTarget = ReferenceFrames.TrackingTarget;
/// Axis representing the current horizontal rotation. Value is in degrees
/// and represents a rotation about the up vector
[Tooltip("Axis representing the current horizontal rotation. Value is in degrees "
+ "and represents a rotation about the up vector.")]
public InputAxis HorizontalAxis = DefaultHorizontal;
/// Axis representing the current vertical rotation. Value is in degrees
/// and represents a rotation about the right vector
[Tooltip("Axis representing the current vertical rotation. Value is in degrees "
+ "and represents a rotation about the right vector.")]
public InputAxis VerticalAxis = DefaultVertical;
/// Axis controlling the scale of the current distance. Value is a scalar
/// multiplier and is applied to the specified camera distance
[Tooltip("Axis controlling the scale of the current distance. Value is a scalar "
+ "multiplier and is applied to the specified camera distance.")]
public InputAxis RadialAxis = DefaultRadial;
// State information
Vector3 m_PreviousOffset;
// Helper object to track the Follow target
Tracker m_TargetTracker;
// 3-rig orbit implementation
Cinemachine3OrbitRig.OrbitSplineCache m_OrbitCache;
///
/// Input axis controller registers here a delegate to call when the camera is reset
///
Action m_ResetHandler;
/// Internal API for inspector
internal Vector3 TrackedPoint { get; private set; }
void OnValidate()
{
Radius = Mathf.Max(0, Radius);
TrackerSettings.Validate();
HorizontalAxis.Validate();
VerticalAxis.Validate();
RadialAxis.Validate();
HorizontalAxis.Restrictions &= ~(InputAxis.RestrictionFlags.NoRecentering | InputAxis.RestrictionFlags.RangeIsDriven);
}
void Reset()
{
TargetOffset = Vector3.zero;
TrackerSettings = TrackerSettings.Default;
OrbitStyle = OrbitStyles.Sphere;
Radius = 10;
Orbits = Cinemachine3OrbitRig.Settings.Default;
HorizontalAxis = DefaultHorizontal;
VerticalAxis = DefaultVertical;
RadialAxis = DefaultRadial;
}
static InputAxis DefaultHorizontal => new () { Value = 0, Range = new Vector2(-180, 180), Wrap = true, Center = 0, Recentering = InputAxis.RecenteringSettings.Default };
static InputAxis DefaultVertical => new () { Value = 17.5f, Range = new Vector2(-10, 45), Wrap = false, Center = 17.5f, Recentering = InputAxis.RecenteringSettings.Default };
static InputAxis DefaultRadial => new () { Value = 1, Range = new Vector2(1, 1), Wrap = false, Center = 1, Recentering = InputAxis.RecenteringSettings.Default };
/// True if component is enabled and has a valid Follow target
public override bool IsValid => enabled && FollowTarget != null;
/// Get the Cinemachine Pipeline stage that this component implements.
/// Always returns the Body stage
public override CinemachineCore.Stage Stage => CinemachineCore.Stage.Body;
///
/// Report maximum damping time needed for this component.
///
/// Highest damping setting in this component
public override float GetMaxDampTime() => TrackerSettings.GetMaxDampTime();
/// Report the available input axes
/// Output list to which the axes will be added
void IInputAxisOwner.GetInputAxes(List axes)
{
axes.Add(new () { DrivenAxis = () => ref HorizontalAxis, Name = "Look Orbit X", Hint = IInputAxisOwner.AxisDescriptor.Hints.X });
axes.Add(new () { DrivenAxis = () => ref VerticalAxis, Name = "Look Orbit Y", Hint = IInputAxisOwner.AxisDescriptor.Hints.Y });
axes.Add(new () { DrivenAxis = () => ref RadialAxis, Name = "Orbit Scale" });
}
/// Register a handler that will be called when input needs to be reset
/// The handler to register
void IInputAxisResetSource.RegisterResetHandler(Action handler) => m_ResetHandler += handler;
/// Unregister a handler that will be called when input needs to be reset
/// The handler to unregister
void IInputAxisResetSource.UnregisterResetHandler(Action handler) => m_ResetHandler -= handler;
/// Inspector checks this and displays warning if no handler
bool IInputAxisResetSource.HasResetHandler => m_ResetHandler != null;
float CinemachineFreeLookModifier.IModifierValueSource.NormalizedModifierValue => GetCameraPoint().w;
Vector3 CinemachineFreeLookModifier.IModifiablePositionDamping.PositionDamping
{
get => TrackerSettings.PositionDamping;
set => TrackerSettings.PositionDamping = value;
}
float CinemachineFreeLookModifier.IModifiableDistance.Distance
{
get => Radius;
set => Radius = value;
}
///
/// For inspector.
/// Get the camera offset corresponding to the normalized position, which ranges from -1...1.
///
/// Camera position in target local space
internal Vector3 GetCameraOffsetForNormalizedAxisValue(float t)
=> m_OrbitCache.SplineValue(Mathf.Clamp01((t + 1) * 0.5f));
Vector4 GetCameraPoint()
{
Vector3 pos;
float t;
if (OrbitStyle == OrbitStyles.ThreeRing)
{
if (m_OrbitCache.SettingsChanged(Orbits))
m_OrbitCache.UpdateOrbitCache(Orbits);
var v = m_OrbitCache.SplineValue(VerticalAxis.GetNormalizedValue());
v *= RadialAxis.Value;
pos = Quaternion.AngleAxis(HorizontalAxis.Value, Vector3.up) * v;
t = v.w;
}
else
{
var rot = Quaternion.Euler(VerticalAxis.Value, HorizontalAxis.Value, 0);
pos = rot * new Vector3(0, 0, -Radius * RadialAxis.Value);
t = VerticalAxis.GetNormalizedValue() * 2 - 1;
}
if (TrackerSettings.BindingMode == BindingMode.LazyFollow)
pos.z = -Mathf.Abs(pos.z);
return new Vector4(pos.x, pos.y, pos.z, t);
}
/// Notification that this virtual camera is going live.
/// Base class implementation does nothing.
/// The camera being deactivated. May be null.
/// Default world Up, set by the CinemachineBrain
/// Delta time for time-based effects (ignore if less than or equal to 0)
/// True if the vcam should do an internal update as a result of this call
public override bool OnTransitionFromCamera(
ICinemachineCamera fromCam, Vector3 worldUp, float deltaTime)
{
m_ResetHandler?.Invoke(); // cancel re-centering
if (fromCam != null
&& (VirtualCamera.State.BlendHint & CameraState.BlendHints.InheritPosition) != 0
&& !CinemachineCore.IsLiveInBlend(VirtualCamera))
{
var state = fromCam.State;
ForceCameraPosition(state.GetFinalPosition(), state.GetFinalOrientation());
return true;
}
return false;
}
///
/// Force the virtual camera to assume a given position and orientation
///
/// World-space position to take
/// World-space orientation to take
public override void ForceCameraPosition(Vector3 pos, Quaternion rot)
{
m_TargetTracker.ForceCameraPosition(this, TrackerSettings.BindingMode, pos, rot, GetCameraPoint());
m_ResetHandler?.Invoke(); // cancel re-centering
if (FollowTarget != null)
{
var dir = pos - FollowTarget.TransformPoint(TargetOffset);
var distance = dir.magnitude;
if (distance > 0.001f)
{
dir /= distance;
if (OrbitStyle == OrbitStyles.ThreeRing)
InferAxesFromPosition_ThreeRing(dir, distance);
else
InferAxesFromPosition_Sphere(dir, distance);
}
}
}
void InferAxesFromPosition_Sphere(Vector3 dir, float distance)
{
var up = VirtualCamera.State.ReferenceUp;
var orient = m_TargetTracker.GetReferenceOrientation(this, TrackerSettings.BindingMode, up);
var localDir = Quaternion.Inverse(orient) * dir;
var r = UnityVectorExtensions.SafeFromToRotation(Vector3.back, localDir, up).eulerAngles;
VerticalAxis.Value = VerticalAxis.ClampValue(TrackerSettings.BindingMode == BindingMode.LazyFollow
? 0 : UnityVectorExtensions.NormalizeAngle(r.x));
HorizontalAxis.Value = HorizontalAxis.ClampValue(UnityVectorExtensions.NormalizeAngle(r.y));
RadialAxis.Value = RadialAxis.ClampValue(distance / Radius);
}
void InferAxesFromPosition_ThreeRing(Vector3 dir, float distance)
{
var up = VirtualCamera.State.ReferenceUp;
var orient = m_TargetTracker.GetReferenceOrientation(this, TrackerSettings.BindingMode, up);
HorizontalAxis.Value = GetHorizontalAxis();
VerticalAxis.Value = GetVerticalAxisClosestValue(out var splinePoint);
RadialAxis.Value = RadialAxis.ClampValue(distance / splinePoint.magnitude);
// local functions
float GetHorizontalAxis()
{
var fwd = (orient * Vector3.back).ProjectOntoPlane(up);
if (!fwd.AlmostZero())
return UnityVectorExtensions.SignedAngle(fwd, dir.ProjectOntoPlane(up), up);
return HorizontalAxis.Value; // Can't calculate, stay conservative
}
float GetVerticalAxisClosestValue(out Vector3 splinePoint)
{
// Rotate the camera pos to the back
var q = UnityVectorExtensions.SafeFromToRotation(up, Vector3.up, up);
var localDir = q * dir;
var flatDir = localDir; flatDir.y = 0;
if (!flatDir.AlmostZero())
{
var angle = UnityVectorExtensions.SignedAngle(flatDir, Vector3.back, Vector3.up);
localDir = Quaternion.AngleAxis(angle, Vector3.up) * localDir;
}
localDir.x = 0;
localDir.Normalize();
// We need to find the minimum of the angle function using steepest descent
var t = SteepestDescent(localDir * distance);
splinePoint = m_OrbitCache.SplineValue(t);
return t <= 0.5f
? Mathf.Lerp(VerticalAxis.Range.x, VerticalAxis.Center, MapTo01(t, 0f, 0.5f)) // [0, 0.5] -> [0, 1] -> [Range.x, Center]
: Mathf.Lerp(VerticalAxis.Center, VerticalAxis.Range.y, MapTo01(t, 0.5f, 1f)); // [0.5, 1] -> [0, 1] -> [Center, Range.Y]
// local functions
float SteepestDescent(Vector3 cameraOffset)
{
const int maxIteration = 5;
const float epsilon = 0.005f;
var x = InitialGuess();
for (var i = 0; i < maxIteration; ++i)
{
var angle = AngleFunction(x);
var slope = SlopeOfAngleFunction(x);
if (Mathf.Abs(slope) < epsilon || Mathf.Abs(angle) < epsilon)
break; // found best
x = Mathf.Clamp01(x - (angle / slope)); // clamping is needed so we don't overshoot
}
return x;
// localFunctions
float AngleFunction(float input)
{
var point = m_OrbitCache.SplineValue(input);
return Mathf.Abs(UnityVectorExtensions.SignedAngle(cameraOffset, point, Vector3.right));
}
// approximating derivative using symmetric difference quotient (finite diff)
float SlopeOfAngleFunction(float input)
{
var angleBehind = AngleFunction(input - epsilon);
var angleAfter = AngleFunction(input + epsilon);
return (angleAfter - angleBehind) / (2f * epsilon);
}
float InitialGuess()
{
if (m_OrbitCache.SettingsChanged(Orbits))
m_OrbitCache.UpdateOrbitCache(Orbits);
const float step = 1.0f / 10;
float best = 0.5f;
float bestAngle = AngleFunction(best);
for (int j = 0; j <= 5; ++j)
{
var t = j * step;
ChooseBestAngle(0.5f + t);
ChooseBestAngle(0.5f - t);
void ChooseBestAngle(float x)
{
var a = AngleFunction(x);
if (a < bestAngle)
{
bestAngle = a;
best = x;
}
}
}
return best;
}
}
static float MapTo01(float valueToMap, float fMin, float fMax) => (valueToMap - fMin) / (fMax - fMin);
}
}
/// This is called to notify the user that a target got warped,
/// so that we can update its internal state to make the camera
/// also warp seamlessly.
/// The object that was warped
/// The amount the target's position changed
public override void OnTargetObjectWarped(Transform target, Vector3 positionDelta)
{
base.OnTargetObjectWarped(target, positionDelta);
if (target == FollowTarget)
m_TargetTracker.OnTargetObjectWarped(positionDelta);
}
/// Positions the virtual camera according to the transposer rules.
/// The current camera state
/// Used for damping. If less than 0, no damping is done.
public override void MutateCameraState(ref CameraState curState, float deltaTime)
{
m_TargetTracker.InitStateInfo(this, deltaTime, TrackerSettings.BindingMode, curState.ReferenceUp);
if (!IsValid)
return;
// Force a reset if enabled, but don't be too aggressive about it,
// because maybe we've just inherited a position
if (deltaTime < 0)// || !VirtualCamera.PreviousStateIsValid || !CinemachineCore.IsLive(VirtualCamera)
m_ResetHandler?.Invoke();
Vector3 offset = GetCameraPoint();
var gotInputX = HorizontalAxis.TrackValueChange();
var gotInputY = VerticalAxis.TrackValueChange();
var gotInputZ = RadialAxis.TrackValueChange();
if (TrackerSettings.BindingMode == BindingMode.LazyFollow)
HorizontalAxis.SetValueAndLastValue(0);
m_TargetTracker.TrackTarget(
this, deltaTime, curState.ReferenceUp, offset, TrackerSettings,
out Vector3 pos, out Quaternion orient);
// Place the camera
offset = orient * offset;
curState.ReferenceUp = orient * Vector3.up;
// Respect minimum target distance on XZ plane
var targetPosition = FollowTargetPosition;
pos += m_TargetTracker.GetOffsetForMinimumTargetDistance(
this, pos, offset, curState.RawOrientation * Vector3.forward,
curState.ReferenceUp, targetPosition);
pos += orient * TargetOffset;
TrackedPoint = pos;
curState.RawPosition = pos + offset;
// Compute the rotation bypass for the lookat target
if (curState.HasLookAt())
{
// Handle the common case where lookAt and follow targets are not the same point.
// If we don't do this, we can get inappropriate vertical damping when offset changes.
var lookAtOfset = orient
* (curState.ReferenceLookAt - (FollowTargetPosition + FollowTargetRotation * TargetOffset));
offset = curState.RawPosition - (pos + lookAtOfset);
}
if (deltaTime >= 0 && VirtualCamera.PreviousStateIsValid
&& m_PreviousOffset.sqrMagnitude > Epsilon && offset.sqrMagnitude > Epsilon)
{
curState.RotationDampingBypass = UnityVectorExtensions.SafeFromToRotation(
m_PreviousOffset, offset, curState.ReferenceUp);
}
m_PreviousOffset = offset;
if (HorizontalAxis.Recentering.Enabled)
UpdateHorizontalCenter(orient);
// Sync recentering if the recenter times match
gotInputX |= gotInputY && (HorizontalAxis.Recentering.Time == VerticalAxis.Recentering.Time);
gotInputX |= gotInputZ && (HorizontalAxis.Recentering.Time == RadialAxis.Recentering.Time);
gotInputY |= gotInputX && (VerticalAxis.Recentering.Time == HorizontalAxis.Recentering.Time);
gotInputY |= gotInputZ && (VerticalAxis.Recentering.Time == RadialAxis.Recentering.Time);
gotInputZ |= gotInputX && (RadialAxis.Recentering.Time == HorizontalAxis.Recentering.Time);
gotInputZ |= gotInputY && (RadialAxis.Recentering.Time == VerticalAxis.Recentering.Time);
HorizontalAxis.UpdateRecentering(deltaTime, gotInputX);
VerticalAxis.UpdateRecentering(deltaTime, gotInputY);
RadialAxis.UpdateRecentering(deltaTime, gotInputZ);
}
void UpdateHorizontalCenter(Quaternion referenceOrientation)
{
// Get the recentering target's forward vector
var fwd = Vector3.forward;
switch (RecenteringTarget)
{
case ReferenceFrames.AxisCenter:
if (TrackerSettings.BindingMode == BindingMode.LazyFollow)
HorizontalAxis.Center = 0;
return;
case ReferenceFrames.ParentObject:
if (transform.parent != null)
fwd = transform.parent.forward;
break;
case ReferenceFrames.TrackingTarget:
if (FollowTarget != null)
fwd = FollowTarget.forward;
break;
case ReferenceFrames.LookAtTarget:
if (LookAtTarget != null)
fwd = LookAtTarget.forward;
break;
}
// Align the axis center to be behind fwd
var up = referenceOrientation * Vector3.up;
fwd.ProjectOntoPlane(up);
HorizontalAxis.Center = -Vector3.SignedAngle(fwd, referenceOrientation * Vector3.forward, up);
}
/// For the inspector
internal Quaternion GetReferenceOrientation() => m_TargetTracker.PreviousReferenceOrientation.normalized;
}
///
/// Helpers for the 3-Orbit rig for the OrbitalFollow component
///
public static class Cinemachine3OrbitRig
{
/// Defines the height and radius for an orbit
[Serializable]
public struct Orbit
{
/// Radius of orbit
[Tooltip("Horizontal radius of the orbit")]
public float Radius;
/// Height relative to target
[Tooltip("Height of the horizontal orbit circle, relative to the target position")]
public float Height;
}
///
/// Settings to define the 3-orbit FreeLook rig using OrbitalFollow.
///
[Serializable]
public struct Settings
{
/// Value to take at the top of the axis range
[Tooltip("Value to take at the top of the axis range")]
public Orbit Top;
/// Value to take at the center of the axis range
[Tooltip("Value to take at the center of the axis range")]
public Orbit Center;
/// Value to take at the bottom of the axis range
[Tooltip("Value to take at the bottom of the axis range")]
public Orbit Bottom;
/// Controls how taut is the line that connects the rigs' orbits, which
/// determines final placement on the Y axis
[Tooltip("Controls how taut is the line that connects the rigs' orbits, "
+ "which determines final placement on the Y axis")]
[Range(0f, 1f)]
public float SplineCurvature;
/// Default orbit rig
public static Settings Default => new Settings
{
SplineCurvature = 0.5f,
Top = new Orbit { Height = 5, Radius = 2 },
Center = new Orbit { Height = 2.25f, Radius = 4 },
Bottom= new Orbit { Height = 0.1f, Radius = 2.5f }
};
}
///
/// Calculates and caches data necessary to implement the 3-orbit rig surface
///
internal struct OrbitSplineCache
{
Settings OrbitSettings;
Vector4[] CachedKnots;
Vector4[] CachedCtrl1;
Vector4[] CachedCtrl2;
/// Test to see if the cache needs to be updated because the orbit settings have changed
/// The current orbit settings, will be compared against the cached settings
/// True if UpdateCache() needs to be called
public bool SettingsChanged(in Settings other)
{
return OrbitSettings.SplineCurvature != other.SplineCurvature
|| OrbitSettings.Top.Height != other.Top.Height || OrbitSettings.Top.Radius != other.Top.Radius
|| OrbitSettings.Center.Height != other.Center.Height || OrbitSettings.Center.Radius != other.Center.Radius
|| OrbitSettings.Bottom.Height != other.Bottom.Height || OrbitSettings.Bottom.Radius != other.Bottom.Radius;
}
///
/// Update the cache according to the new orbit settings.
/// This does a bunch of expensive calculations so should only be called when necessary.
///
///
public void UpdateOrbitCache(in Settings orbits)
{
OrbitSettings = orbits;
float t = orbits.SplineCurvature;
CachedKnots = new Vector4[5];
CachedCtrl1 = new Vector4[5];
CachedCtrl2 = new Vector4[5];
CachedKnots[1] = new Vector4(0, orbits.Bottom.Height, -orbits.Bottom.Radius, -1);
CachedKnots[2] = new Vector4(0, orbits.Center.Height, -orbits.Center.Radius, 0);
CachedKnots[3] = new Vector4(0, orbits.Top.Height, -orbits.Top.Radius, 1);
CachedKnots[0] = Vector4.Lerp(CachedKnots[1] + (CachedKnots[1] - CachedKnots[2]) * 0.5f, Vector4.zero, t);
CachedKnots[4] = Vector4.Lerp(CachedKnots[3] + (CachedKnots[3] - CachedKnots[2]) * 0.5f, Vector4.zero, t);
SplineHelpers.ComputeSmoothControlPoints(ref CachedKnots, ref CachedCtrl1, ref CachedCtrl2);
}
/// Get the value of a point on the spline curve
/// Where on the spline arc, with 0...1 t==0.5 being the center orbit.
/// Point on the spline along the surface defined by the orbits.
/// XYZ is the point itself, and W ranges from 0 on the bottom to 2 on the top,
/// with 1 being the center.
public Vector4 SplineValue(float t)
{
if (CachedKnots == null)
return Vector4.zero;
int n = 1;
if (t > 0.5f)
{
t -= 0.5f;
n = 2;
}
Vector4 pos = SplineHelpers.Bezier3(
t * 2f, CachedKnots[n], CachedCtrl1[n], CachedCtrl2[n], CachedKnots[n+1]);
// -1 <= w <= 1, where 0 == center
pos.w = SplineHelpers.Bezier1(
t * 2f, CachedKnots[n].w, CachedCtrl1[n].w, CachedCtrl2[n].w, CachedKnots[n+1].w);
return pos;
}
}
}
}