#if CINEMACHINE_PHYSICS_2D
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Serialization;
namespace Unity.Cinemachine
{
///
/// An add-on module for Cinemachine Camera that post-processes the final position
/// of the virtual camera. It will confine the camera's position such that the screen edges stay
/// within a shape defined by a 2D polygon. This will work for orthographic or perspective cameras,
/// provided that the camera's forward vector remains parallel to the bounding shape's normal,
/// i.e. that the camera is looking straight at the polygon, and not obliquely at it.
///
/// When confining the camera, the camera's view size at the polygon plane is considered, and
/// also its aspect ratio. Based on this information and the input polygon, a second (smaller)
/// polygon is computed to which the camera's transform is constrained. Computation of this secondary
/// polygon is nontrivial and expensive, so it should be done only when absolutely necessary.
///
/// When the Orthographic Size or Field of View of the Cinemachine Camera's lens changes, Cinemachine will not
/// automatically adjust the Confiner for efficiency reasons. To adjust the Confiner, call InvalidateLensCache().
///
/// Confiner2D pre-calculates a cache to speed up calculation.
/// The cache needs to be recomputed in the following circumstances:
/// - when the input polygon's points change
/// - when the input polygon is non-uniformly scaled
/// - when the input polygon is rotated
///
/// For efficiency reasons, Cinemachine will not automatically regenerate the cache.
/// It is the responsibility of the client to call the InvalidateBoundingShapeCache() method to trigger
/// a recalculation. An inspector button is also provided for this purpose.
///
/// If the input polygon scales uniformly or translates, the cache remains valid. If the
/// polygon rotates, then the cache degrades in quality (more or less depending on the aspect
/// ratio - it's better if the ratio is close to 1:1) but can still be used.
/// Regenerating it will eliminate the imperfections.
///
/// When the Oversize Window is enabled an additional pre-calculation step is added to the caching process.
/// This cache is not a single polygon, but rather a family of polygons. The number of
/// polygons in this family will depend on the complexity of the input polygon, and the maximum
/// expected camera view size. The MaxWindowSize property is provided to give a hint to the
/// algorithm to stop generating polygons for camera view sizes larger than the one specified.
/// This can represent a substantial cost saving when regenerating the cache, so it is a good
/// idea to set it carefully. Leaving it at 0 will cause the maximum number of polygons to be generated.
///
[AddComponentMenu("Cinemachine/Procedural/Extensions/Cinemachine Confiner 2D")]
[SaveDuringPlay]
[ExecuteAlways]
[DisallowMultipleComponent]
[HelpURL(Documentation.BaseURL + "manual/CinemachineConfiner2D.html")]
public class CinemachineConfiner2D : CinemachineExtension
{
/// The 2D shape within which the camera is to be contained.
[Tooltip("The 2D shape within which the camera is to be contained. " +
"Can be polygon-, box-, or composite collider 2D.\n\n" +
"Remark: When assigning a GameObject here in the editor, " +
"this will be set to the first Collider2D found on the assigned GameObject!")]
[FormerlySerializedAs("m_BoundingShape2D")]
public Collider2D BoundingShape2D;
/// Damping applied automatically around corners to avoid jumps.
[Tooltip("Damping applied around corners to avoid jumps. Higher numbers are more gradual.")]
[Range(0, 5)]
[FormerlySerializedAs("m_Damping")]
public float Damping;
/// Size of the slow-down zone at the edge of the bounding shape.
[Tooltip("Size of the slow-down zone at the edge of the bounding shape.")]
public float SlowingDistance = 0;
///
/// Settings to optimize computation and memory costs in the event that the
/// window size is expected to be larger than will fit inside the confining shape.
///
[Serializable]
public struct OversizeWindowSettings
{
///
/// Enable optimizing of computation and memory costs in the event that the
/// window size is expected to be larger than will fit inside the confining shape.
/// Enable only if needed, because it's costly.
///
[Tooltip("Enable optimizing of computation and memory costs in the event that the "
+ "window size is expected to be larger than will fit inside the confining shape.\n"
+ "Enable only if needed, because it's costly")]
public bool Enabled;
///
/// To optimize computation and memory costs, set this to the largest view size that the camera
/// is expected to have. The confiner will not compute a polygon cache for frustum sizes larger
/// than this. This refers to the size in world units of the frustum at the confiner plane
/// (for orthographic cameras, this is just the orthographic size). If set to 0, then this
/// parameter is ignored and a polygon cache will be calculated for all potential window sizes.
///
[Tooltip("To optimize computation and memory costs, set this to the largest view size that the "
+ "camera is expected to have. The confiner will not compute a polygon cache for frustum "
+ "sizes larger than this. This refers to the size in world units of the frustum at the "
+ "confiner plane (for orthographic cameras, this is just the orthographic size). If set "
+ "to 0, then this parameter is ignored and a polygon cache will be calculated for all "
+ "potential window sizes.")]
public float MaxWindowSize;
}
///
/// Settings to optimize computation and memory costs in the event that the
/// window size is expected to be larger than will fit inside the confining shape.
///
[FoldoutWithEnabledButton]
public OversizeWindowSettings OversizeWindow;
class VcamExtraState : VcamExtraStateBase
{
public ConfinerOven.BakedSolution BakedSolution;
public Vector3 PreviousDisplacement;
public Vector3 DampedDisplacement;
public Vector3 PreviousCameraPosition;
public float FrustumHeight;
};
List m_ExtraStateCache;
ShapeCache m_ShapeCache;
[SerializeField, HideInInspector, FormerlySerializedAs("m_MaxWindowSize")]
float m_LegacyMaxWindowSize = -2; // -2 means there's no legacy upgrade to do
const float k_CornerAngleThreshold = 10f;
void OnValidate()
{
const float maxComputationTimePerFrameInSeconds = 1f / 120f;
Damping = Mathf.Max(0, Damping);
SlowingDistance = Mathf.Max(0, SlowingDistance);
m_ShapeCache.maxComputationTimePerFrameInSeconds = maxComputationTimePerFrameInSeconds;
OversizeWindow.MaxWindowSize = Mathf.Max(0, OversizeWindow.MaxWindowSize);
// Legacy upgrade
if (m_LegacyMaxWindowSize != -2)
{
OversizeWindow = new ()
{
Enabled = m_LegacyMaxWindowSize >= 0,
MaxWindowSize = Mathf.Max(0, m_LegacyMaxWindowSize)
};
m_LegacyMaxWindowSize = -2;
}
}
void Reset()
{
Damping = 0.5f;
SlowingDistance = 5;
OversizeWindow = new ();
}
///
/// Report maximum damping time needed for this component.
///
/// Highest damping setting in this component
public override float GetMaxDampTime()
=> Mathf.Max(Damping, SlowingDistance * 0.2f); // just an approximation - we don't know the time
/// This is called to notify the extension that a target got warped,
/// so that the extension can update its internal state to make the camera
/// also warp seamlessly. Base class implementation does nothing.
/// The camera to warp
/// The object that was warped
/// The amount the target's position changed
public override void OnTargetObjectWarped(
CinemachineVirtualCameraBase vcam, Transform target, Vector3 positionDelta)
{
var extra = GetExtraState(vcam);
if (extra.Vcam.Follow == target)
extra.PreviousCameraPosition += positionDelta;
}
///
/// Invalidates the lens cache for the Cinemachine Camera that ownes this Confiner.
/// Call this when when the Field of View or Orthographic Size changes.
/// Calculating the lens cache is fast, but causes allocations.
///
public void InvalidateLensCache()
{
m_ExtraStateCache ??= new();
GetAllExtraStates(m_ExtraStateCache);
for (int i = 0; i < m_ExtraStateCache.Count; ++i)
{
var extra = m_ExtraStateCache[i];
if (extra.Vcam != null)
{
extra.BakedSolution = null;
extra.FrustumHeight = 0;
}
}
}
///
/// Invalidates Bounding Shape Cache, so a new one is computed next frame.
/// The re-computation is costly. This recomputes the bounding shape cache, and
/// the computed confiner cache.
/// Call this when the input bounding shape changes (non-uniform scale, rotation, or
/// points are moved, added or deleted).
///
///
/// It is much more efficient to have more Cinemachine Cameras with different input bounding shapes and
/// blend between them instead of changing one Confiner2D's input bounding shape and calling this over and over.
///
public void InvalidateBoundingShapeCache()
{
m_ShapeCache.Invalidate();
InvalidateLensCache();
}
[Obsolete("Call InvalidateBoundingShapeCache() instead.", false)]
public void InvalidateCache() => InvalidateBoundingShapeCache();
///
/// Callback to do the camera confining
///
/// The virtual camera being processed
/// The current pipeline stage
/// The current virtual camera state
/// The current applicable deltaTime
protected override void PostPipelineStageCallback(
CinemachineVirtualCameraBase vcam,
CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
{
if (stage == CinemachineCore.Stage.Body)
{
var aspectRatio = state.Lens.Aspect;
if (!m_ShapeCache.ValidateCache(BoundingShape2D, OversizeWindow, aspectRatio, out bool confinerStateChanged))
return; // invalid path
var extra = GetExtraState(vcam);
var camPos = state.GetCorrectedPosition();
// Make sure we have a solution for our current frustum size
if (confinerStateChanged || extra.BakedSolution == null || !extra.BakedSolution.IsValid())
{
// convert frustum height from world to baked space. deltaWorldToBaked.lossyScale is always uniform.
var deltaW = m_ShapeCache.DeltaWorldToBaked;
m_ShapeCache.AspectRatio = aspectRatio;
extra.FrustumHeight =
CalculateHalfFrustumHeight(state.Lens, deltaW.MultiplyPoint3x4(camPos).z) * deltaW.lossyScale.x;
extra.BakedSolution = m_ShapeCache.ConfinerOven.GetBakedSolution(extra.FrustumHeight);
}
var fwd = state.GetCorrectedOrientation() * Vector3.forward;
var newPos = ConfinePoint(camPos, extra, fwd);
if (SlowingDistance > Epsilon && deltaTime >= 0 && vcam.PreviousStateIsValid)
{
// Reduce speed if moving towards the edge and close enough to it
var prevPos = extra.PreviousCameraPosition;
var dir = newPos - prevPos;
var speed = dir.magnitude;
if (speed > Epsilon)
{
var t = GetDistanceFromEdge(prevPos, dir / speed, SlowingDistance, extra, fwd) / SlowingDistance;
// This formula is found to give a smooth slowing curve while ensuring
// that it comes to a full stop in a reasonable time
newPos = Vector3.Lerp(prevPos, newPos, t * t * t + 0.05f);
}
}
// Remember the desired displacement for next frame
var prev = extra.PreviousDisplacement;
var displacement = newPos - camPos;
extra.PreviousDisplacement = displacement;
if (!vcam.PreviousStateIsValid || deltaTime < 0 || Damping <= 0)
extra.DampedDisplacement = Vector3.zero;
else
{
// If a big change from previous frame's desired displacement is detected,
// assume we are going around a corner and extract that difference for damping
if (prev.sqrMagnitude > 0.01f && Vector2.Angle(prev, displacement) > k_CornerAngleThreshold)
extra.DampedDisplacement += displacement - prev;
extra.DampedDisplacement -= Damper.Damp(extra.DampedDisplacement, Damping, deltaTime);
displacement -= extra.DampedDisplacement;
}
state.PositionCorrection += displacement;
extra.PreviousCameraPosition = state.GetCorrectedPosition();
}
}
Vector3 ConfinePoint(Vector3 pos, VcamExtraState extra, Vector3 fwd)
{
var posLocal = m_ShapeCache.DeltaWorldToBaked.MultiplyPoint3x4(pos);
var newPos = m_ShapeCache.DeltaBakedToWorld.MultiplyPoint3x4(
extra.BakedSolution.ConfinePoint(posLocal));
// Don't move the point along the fwd axis
return newPos - fwd * Vector3.Dot(fwd, newPos - pos);
}
// Returns distance from edge in direction of motion, or max if distance is greater than max.
// dirUnit must be unit length.
float GetDistanceFromEdge(Vector3 p, Vector3 dirUnit, float max, VcamExtraState extra, Vector3 fwd)
{
p += dirUnit * max;
return max - (ConfinePoint(p, extra, fwd) - p).magnitude;
}
///
/// Calculates half frustum height for orthographic or perspective camera.
/// For more info on frustum height, see .
///
/// Camera Lens for checking if Orthographic or Perspective
/// camera's z pos in local space
/// Frustum height of the camera
public static float CalculateHalfFrustumHeight(in LensSettings lens, in float cameraPosLocalZ)
{
float frustumHeight;
if (lens.Orthographic)
frustumHeight = lens.OrthographicSize;
else
{
// distance between the collider's plane and the camera
float distance = cameraPosLocalZ;
frustumHeight = distance * Mathf.Tan(lens.FieldOfView * 0.5f * Mathf.Deg2Rad);
}
return Mathf.Abs(frustumHeight);
}
///
/// ShapeCache: contains all states that dependent only on the settings in the confiner.
///
struct ShapeCache
{
public ConfinerOven ConfinerOven;
public List> OriginalPath; // in baked space, not including offset
// These account for offset and transform change since baking
public Matrix4x4 DeltaWorldToBaked;
public Matrix4x4 DeltaBakedToWorld;
public float AspectRatio;
OversizeWindowSettings m_OversizeWindowSettings;
internal float maxComputationTimePerFrameInSeconds;
Matrix4x4 m_BakedToWorld; // defines baked space
Collider2D m_BoundingShape2D;
///
/// Invalidates shapeCache
///
public void Invalidate()
{
m_OversizeWindowSettings = new ();
DeltaBakedToWorld = DeltaWorldToBaked = Matrix4x4.identity;
m_BoundingShape2D = null;
OriginalPath = null;
ConfinerOven = null;
}
///
/// Checks if we have a valid confiner state cache. Calculates cache if it is invalid (outdated or empty).
///
/// Bounding shape
/// Max Window size (calculation upper bound)
/// Aspect ratio
/// True, if the baked confiner state has changed.
/// False, otherwise.
/// True, if input is valid. False, otherwise.
public bool ValidateCache(
Collider2D boundingShape2D,
OversizeWindowSettings oversize, float aspectRatio,
out bool confinerStateChanged)
{
confinerStateChanged = false;
if (IsValid(boundingShape2D, oversize, aspectRatio))
{
// Advance confiner baking
if (ConfinerOven.State == ConfinerOven.BakingState.BAKING)
{
ConfinerOven.BakeConfiner(maxComputationTimePerFrameInSeconds);
// If no longer baking, then confinerStateChanged
confinerStateChanged = ConfinerOven.State != ConfinerOven.BakingState.BAKING;
}
// Update in case the polygon's transform changed
CalculateDeltaTransformationMatrix();
// If delta world to baked scale is uniform, cache is valid.
Vector2 lossyScaleXY = DeltaWorldToBaked.lossyScale;
if (lossyScaleXY.IsUniform())
return true;
}
Invalidate();
if (boundingShape2D == null)
return false;
confinerStateChanged = true;
switch (boundingShape2D)
{
case PolygonCollider2D polygonCollider2D:
{
OriginalPath = new List>();
// Cache the current world-space shape
m_BakedToWorld = boundingShape2D.transform.localToWorldMatrix;
for (var i = 0; i < polygonCollider2D.pathCount; ++i)
{
var path = polygonCollider2D.GetPath(i);
var dst = new List();
for (var j = 0; j < path.Length; ++j)
dst.Add(m_BakedToWorld.MultiplyPoint3x4(path[j]));
OriginalPath.Add(dst);
}
}
break;
case BoxCollider2D boxCollider2D:
{
// Cache the current world-space shape
m_BakedToWorld = boundingShape2D.transform.localToWorldMatrix;
var size = boxCollider2D.size;
var halfY = size.y / 2f;
var halfX = size.x / 2f;
var topLeft = m_BakedToWorld.MultiplyPoint3x4(new Vector3(-halfX, halfY));
var topRight = m_BakedToWorld.MultiplyPoint3x4(new Vector3(halfX, halfY));
var btmRight = m_BakedToWorld.MultiplyPoint3x4(new Vector3(halfX, -halfY));
var btmLeft = m_BakedToWorld.MultiplyPoint3x4(new Vector3(-halfX, -halfY));
OriginalPath = new List>
{
new() { topLeft, topRight, btmRight, btmLeft }
};
}
break;
case CompositeCollider2D compositeCollider2D:
{
OriginalPath = new List>();
// Cache the current world-space shape
m_BakedToWorld = boundingShape2D.transform.localToWorldMatrix;
var path = new Vector2[compositeCollider2D.pointCount];
for (var i = 0; i < compositeCollider2D.pathCount; ++i)
{
var numPoints = compositeCollider2D.GetPath(i, path);
var dst = new List();
for (var j = 0; j < numPoints; ++j)
dst.Add(m_BakedToWorld.MultiplyPoint3x4(path[j]));
OriginalPath.Add(dst);
}
}
break;
default:
return false;
}
if (!HasAnyPoints(OriginalPath))
return false; // polygon or composite collider with 0 points
ConfinerOven = new ConfinerOven(OriginalPath, aspectRatio, oversize.Enabled ? oversize.MaxWindowSize : -1);
m_BoundingShape2D = boundingShape2D;
m_OversizeWindowSettings = oversize;
AspectRatio = aspectRatio;
CalculateDeltaTransformationMatrix();
return true;
// local function
static bool HasAnyPoints(List> originalPath)
{
for (var i = 0; i < originalPath.Count; i++)
if (originalPath[i].Count != 0)
return true;
return false;
}
}
bool IsValid(in Collider2D boundingShape2D, in OversizeWindowSettings oversize, float aspectRatio)
{
return boundingShape2D != null && m_BoundingShape2D != null
&& m_BoundingShape2D == boundingShape2D // same boundingShape?
&& OriginalPath != null // first time?
&& ConfinerOven != null // cache not empty?
&& Math.Abs(AspectRatio - aspectRatio) < Epsilon // aspect ratio changed?
&& m_OversizeWindowSettings.Enabled == oversize.Enabled // max ortho changed?
&& Mathf.Abs(m_OversizeWindowSettings.MaxWindowSize - oversize.MaxWindowSize) < Epsilon;
}
void CalculateDeltaTransformationMatrix()
{
// Account for current collider offset (in local space) and
// incorporate the world-space delta that the confiner has moved since baking
var m = Matrix4x4.Translate(-m_BoundingShape2D.offset) *
m_BoundingShape2D.transform.worldToLocalMatrix;
DeltaWorldToBaked = m_BakedToWorld * m;
DeltaBakedToWorld = DeltaWorldToBaked.inverse;
}
}
#if UNITY_EDITOR
// Used by editor gizmo drawer
internal bool GetGizmoPaths(
out List> originalPath,
ref List> currentPath,
out Matrix4x4 pathLocalToWorld)
{
originalPath = m_ShapeCache.OriginalPath;
pathLocalToWorld = m_ShapeCache.DeltaBakedToWorld;
currentPath.Clear();
m_ExtraStateCache ??= new();
GetAllExtraStates(m_ExtraStateCache);
for (int i = 0; i < m_ExtraStateCache.Count; ++i)
{
var e = m_ExtraStateCache[i];
if (e.Vcam != null && e.BakedSolution != null)
currentPath.AddRange(e.BakedSolution.GetBakedPath());
}
return originalPath != null;
}
// Used by editor script to notify user that the confiner cannot fit the camera
internal bool IsCameraLensOversized()
{
if (!LensCacheIsValid())
{
InvalidateLensCache();
UnityEditor.EditorUtility.SetDirty(this);
}
if (BoundingShape2D == null)
return false;
if (m_ShapeCache.ConfinerOven != null && m_ShapeCache.ConfinerOven.m_Skeleton.Count > 0)
return true; // there is a skeleton, that means some parts are collapsed -> oversized
m_ExtraStateCache ??= new();
GetAllExtraStates(m_ExtraStateCache);
for (int i = 0; i < m_ExtraStateCache.Count; ++i)
{
var extra = m_ExtraStateCache[i];
if (extra.Vcam != null && extra.BakedSolution != null)
{
var solution = extra.BakedSolution.m_Solution;
if (solution.Count == 1 && solution[0].Count == 1)
return true; // shrank down to mid point -> oversized
if (m_ShapeCache.OriginalPath != null && solution.Count != m_ShapeCache.OriginalPath.Count)
return true; // polygon count of the input and solution differs -> oversized
}
}
return false;
}
bool LensCacheIsValid()
{
m_ExtraStateCache ??= new();
GetAllExtraStates(m_ExtraStateCache);
for (int i = 0; i < m_ExtraStateCache.Count; ++i)
{
var extra = m_ExtraStateCache[i];
if (extra.Vcam != null)
{
var state = extra.Vcam.State;
var lens = state.Lens;
var deltaW = m_ShapeCache.DeltaWorldToBaked;
var frustum = CalculateHalfFrustumHeight(lens, deltaW.MultiplyPoint3x4(state.GetCorrectedPosition()).z);
if (Mathf.Abs(extra.FrustumHeight - frustum * deltaW.lossyScale.x) > Epsilon)
return false;
}
}
return true;
}
internal float BakeProgress() => m_ShapeCache.ConfinerOven != null ? m_ShapeCache.ConfinerOven.bakeProgress : 0f;
internal bool ConfinerOvenTimedOut() => m_ShapeCache.ConfinerOven != null &&
m_ShapeCache.ConfinerOven.State == ConfinerOven.BakingState.TIMEOUT;
internal bool IsConfinerOvenNull() => m_ShapeCache.ConfinerOven == null;
#endif
}
}
#endif