#if !UNITY_2019_3_OR_NEWER #define CINEMACHINE_PHYSICS_2D #endif using System; using System.Collections.Generic; using Cinemachine.Utility; using UnityEditor; using UnityEngine; namespace Cinemachine { #if CINEMACHINE_PHYSICS_2D /// /// /// An add-on module for Cinemachine Virtual 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. /// /// /// /// The cached secondary polygon 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 inner polygon /// in these cases, and it is the responsibility of the client to call the InvalidateCache() /// method to trigger the 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. /// /// /// /// The cached secondary polygon is not a single polygon, but rather a family of polygons from /// which a member is chosen depending on the current size of the camera view. The number of /// polygons in this family will depend on the complexity of the input polygon, and the maximum /// expected camera view size. The MaxOrthoSize 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("")] // Hide in menu [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 a 2D polygon or 2D composite collider.")] public Collider2D m_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)] public float m_Damping; /// /// 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 m_MaxWindowSize; float m_MaxComputationTimePerFrameInSeconds = 1f / 120f; /// Invalidates cache and consequently trigger a rebake at next iteration. public void InvalidateCache() { m_shapeCache.Invalidate(); } /// Validates cache /// Aspect ratio of camera. /// Returns true if the cache could be validated. False, otherwise. public bool ValidateCache(float cameraAspectRatio) { return m_shapeCache.ValidateCache(m_BoundingShape2D, m_MaxWindowSize, cameraAspectRatio, out _); } const float k_cornerAngleTreshold = 10f; /// /// 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( m_BoundingShape2D, m_MaxWindowSize, aspectRatio, out bool confinerStateChanged)) { return; // invalid path } var oldCameraPos = state.CorrectedPosition; var cameraPosLocal = m_shapeCache.m_DeltaWorldToBaked.MultiplyPoint3x4(oldCameraPos); var currentFrustumHeight = CalculateHalfFrustumHeight(state, cameraPosLocal.z); // convert frustum height from world to baked space. deltaWorldToBaked.lossyScale is always uniform. var bakedSpaceFrustumHeight = currentFrustumHeight * m_shapeCache.m_DeltaWorldToBaked.lossyScale.x; // Make sure we have a solution for our current frustum size var extra = GetExtraState(vcam); extra.m_vcam = vcam; if (confinerStateChanged || extra.m_BakedSolution == null || !extra.m_BakedSolution.IsValid()) { extra.m_BakedSolution = m_shapeCache.m_confinerOven.GetBakedSolution(bakedSpaceFrustumHeight); } cameraPosLocal = extra.m_BakedSolution.ConfinePoint(cameraPosLocal); var newCameraPos = m_shapeCache.m_DeltaBakedToWorld.MultiplyPoint3x4(cameraPosLocal); // Don't move the camera along its z-axis var fwd = state.CorrectedOrientation * Vector3.forward; newCameraPos -= fwd * Vector3.Dot(fwd, newCameraPos - oldCameraPos); // Remember the desired displacement for next frame var prev = extra.m_PreviousDisplacement; var displacement = newCameraPos - oldCameraPos; extra.m_PreviousDisplacement = displacement; if (!VirtualCamera.PreviousStateIsValid || deltaTime < 0 || m_Damping <= 0) extra.m_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_cornerAngleTreshold) extra.m_DampedDisplacement += displacement - prev; extra.m_DampedDisplacement -= Damper.Damp(extra.m_DampedDisplacement, m_Damping, deltaTime); displacement -= extra.m_DampedDisplacement; } state.PositionCorrection += displacement; } } /// /// Calculates half frustum height for orthographic or perspective camera. /// For more info on frustum height, see /// /// CameraState for checking if Orthographic or Perspective /// vcam, to check its position /// Frustum height of the camera float CalculateHalfFrustumHeight(in CameraState state, in float cameraPosLocalZ) { float frustumHeight; if (state.Lens.Orthographic) { frustumHeight = state.Lens.OrthographicSize; } else { // distance between the collider's plane and the camera float distance = cameraPosLocalZ; frustumHeight = distance * Mathf.Tan(state.Lens.FieldOfView * 0.5f * Mathf.Deg2Rad); } return Mathf.Abs(frustumHeight); } class VcamExtraState { public Vector3 m_PreviousDisplacement; public Vector3 m_DampedDisplacement; public ConfinerOven.BakedSolution m_BakedSolution; public CinemachineVirtualCameraBase m_vcam; }; ShapeCache m_shapeCache; /// /// ShapeCache: contains all states that dependent only on the settings in the confiner. /// struct ShapeCache { public ConfinerOven m_confinerOven; public List> m_OriginalPath; // in baked space, not including offset // These account for offset and transform change since baking public Matrix4x4 m_DeltaWorldToBaked; public Matrix4x4 m_DeltaBakedToWorld; float m_aspectRatio; float m_maxWindowSize; internal float m_maxComputationTimePerFrameInSeconds; Matrix4x4 m_bakedToWorld; // defines baked space Collider2D m_boundingShape2D; /// /// Invalidates shapeCache /// public void Invalidate() { m_aspectRatio = 0; m_maxWindowSize = -1; m_DeltaBakedToWorld = m_DeltaWorldToBaked = Matrix4x4.identity; m_boundingShape2D = null; m_OriginalPath = null; m_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 /// Aspect ratio/param> /// True, if the baked confiner state has changed. /// False, otherwise. /// True, if input is valid. False, otherwise. public bool ValidateCache( Collider2D boundingShape2D, float maxWindowSize, float aspectRatio, out bool confinerStateChanged) { confinerStateChanged = false; if (IsValid(boundingShape2D, aspectRatio, maxWindowSize)) { // Advance confiner baking if (m_confinerOven.State == ConfinerOven.BakingState.BAKING) { m_confinerOven.BakeConfiner(m_maxComputationTimePerFrameInSeconds); // If no longer baking, then confinerStateChanged confinerStateChanged = m_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 = m_DeltaWorldToBaked.lossyScale; if (lossyScaleXY.IsUniform()) { return true; } } Invalidate(); confinerStateChanged = true; Type colliderType = boundingShape2D == null ? null: boundingShape2D.GetType(); if (colliderType == typeof(PolygonCollider2D)) { var poly = boundingShape2D as PolygonCollider2D; m_OriginalPath = new List>(); // Cache the current worldspace shape m_bakedToWorld = boundingShape2D.transform.localToWorldMatrix; for (int i = 0; i < poly.pathCount; ++i) { Vector2[] path = poly.GetPath(i); List dst = new List(); for (int j = 0; j < path.Length; ++j) dst.Add(m_bakedToWorld.MultiplyPoint3x4(path[j])); m_OriginalPath.Add(dst); } } else if (colliderType == typeof(CompositeCollider2D)) { var poly = boundingShape2D as CompositeCollider2D; m_OriginalPath = new List>(); // Cache the current worldspace shape m_bakedToWorld = boundingShape2D.transform.localToWorldMatrix; var path = new Vector2[poly.pointCount]; for (int i = 0; i < poly.pathCount; ++i) { int numPoints = poly.GetPath(i, path); List dst = new List(); for (int j = 0; j < numPoints; ++j) dst.Add(m_bakedToWorld.MultiplyPoint3x4(path[j])); m_OriginalPath.Add(dst); } } else { return false; // input collider is invalid } m_confinerOven = new ConfinerOven(m_OriginalPath, aspectRatio, maxWindowSize); m_aspectRatio = aspectRatio; m_boundingShape2D = boundingShape2D; m_maxWindowSize = maxWindowSize; CalculateDeltaTransformationMatrix(); return true; } bool IsValid(in Collider2D boundingShape2D, in float aspectRatio, in float maxOrthoSize) { return boundingShape2D != null && m_boundingShape2D != null && m_boundingShape2D == boundingShape2D && // same boundingShape? m_OriginalPath != null && // first time? m_confinerOven != null && // cache not empty? Mathf.Abs(m_aspectRatio - aspectRatio) < UnityVectorExtensions.Epsilon && // aspect changed? Mathf.Abs(m_maxWindowSize - maxOrthoSize) < UnityVectorExtensions.Epsilon; // max ortho changed? } void CalculateDeltaTransformationMatrix() { // Account for current collider offset (in local space) and // incorporate the worldspace delta that the confiner has moved since baking var m = Matrix4x4.Translate(-m_boundingShape2D.offset) * m_boundingShape2D.transform.worldToLocalMatrix; m_DeltaWorldToBaked = m_bakedToWorld * m; m_DeltaBakedToWorld = m_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.m_OriginalPath; pathLocalToWorld = m_shapeCache.m_DeltaBakedToWorld; currentPath.Clear(); var allExtraStates = GetAllExtraStates(); for (var i = 0; i < allExtraStates.Count; ++i) { var e = allExtraStates[i]; if (e.m_BakedSolution != null) { currentPath.AddRange(e.m_BakedSolution.GetBakedPath()); } } return originalPath != null; } internal float BakeProgress() => m_shapeCache.m_confinerOven != null ? m_shapeCache.m_confinerOven.bakeProgress : 0f; internal bool ConfinerOvenTimedOut() => m_shapeCache.m_confinerOven != null && m_shapeCache.m_confinerOven.State == ConfinerOven.BakingState.TIMEOUT; #endif void OnValidate() { m_Damping = Mathf.Max(0, m_Damping); m_shapeCache.m_maxComputationTimePerFrameInSeconds = m_MaxComputationTimePerFrameInSeconds; } void Reset() { m_Damping = 0.5f; m_MaxWindowSize = -1; } } #endif }