#if CINEMACHINE_PHYSICS_2D using System; using System.Collections.Generic; using UnityEngine; namespace Unity.Cinemachine { using IntPoint = Point64; using IntRect = Rect64; /// /// Responsible for baking confiners via BakeConfiner function. /// class ConfinerOven { public class BakedSolution { float m_FrustumSizeIntSpace; readonly AspectStretcher m_AspectStretcher; readonly bool m_HasBones; readonly double m_SqrPolygonDiagonal; List> m_OriginalPolygon; internal List> m_Solution; const double k_ClipperEpsilon = 0.01f * k_FloatToIntScaler; public BakedSolution( float aspectRatio, float frustumHeight, bool hasBones, Rect polygonBounds, List> originalPolygon, List> solution) { m_AspectStretcher = new AspectStretcher(aspectRatio, polygonBounds.center.x); m_FrustumSizeIntSpace = frustumHeight * k_FloatToIntScaler; m_HasBones = hasBones; m_OriginalPolygon = originalPolygon; m_Solution = solution; float polygonSizeX = polygonBounds.width / aspectRatio * k_FloatToIntScaler; float polygonSizeY = polygonBounds.height * k_FloatToIntScaler; m_SqrPolygonDiagonal = polygonSizeX * polygonSizeX + polygonSizeY * polygonSizeY; } public bool IsValid() => m_Solution != null; public Vector2 ConfinePoint(in Vector2 pointToConfine) { if (m_Solution.Count <= 0) return pointToConfine; // empty confiner -> no need to confine Vector2 pInConfinerSpace = m_AspectStretcher.Stretch(pointToConfine); var p = new IntPoint(pInConfinerSpace.x * k_FloatToIntScaler, pInConfinerSpace.y * k_FloatToIntScaler); for (int i = 0; i < m_Solution.Count; ++i) { if (Clipper.PointInPolygon(p, m_Solution[i]) != PointInPolygonResult.IsOutside) { return pointToConfine; // inside, no confinement needed } } // If the poly has bones and if the position to confine is not outside of the original // bounding shape, then it is possible that the bone in a neighbouring section // is closer than the bone in the correct section of the polygon, if the current section // is very large and the neighbouring section is small. In that case, we'll need to // add an extra check when calculating the nearest point. bool checkIntersectOriginal = m_HasBones && IsInsideOriginal(p); // Confine point IntPoint closest = p; double minDistance = double.MaxValue; for (int i = 0; i < m_Solution.Count; ++i) { int numPoints = m_Solution[i].Count; for (int j = 0; j < numPoints; ++j) { IntPoint l1 = m_Solution[i][j]; IntPoint l2 = m_Solution[i][(j + 1) % numPoints]; IntPoint c = IntPointLerp(l1, l2, ClosestPointOnSegment(p, l1, l2)); double diffX = Mathf.Abs(p.X - c.X); double diffY = Mathf.Abs(p.Y - c.Y); double distance = diffX * diffX + diffY * diffY; // penalty for points from which the target is not visible, preferring visibility over proximity if (diffX > m_FrustumSizeIntSpace || diffY > m_FrustumSizeIntSpace) { distance += m_SqrPolygonDiagonal; // penalty is the biggest distance between any two points } if (distance < minDistance && (!checkIntersectOriginal || !DoesIntersectOriginal(p, c))) { minDistance = distance; closest = c; } } } var result = new Vector2(closest.X * k_IntToFloatScaler, closest.Y * k_IntToFloatScaler); return m_AspectStretcher.Unstretch(result); // local functions IntPoint IntPointLerp(IntPoint a, IntPoint b, float lerp) { return new IntPoint { X = Mathf.RoundToInt(a.X + (b.X - a.X) * lerp), Y = Mathf.RoundToInt(a.Y + (b.Y - a.Y) * lerp), }; } bool IsInsideOriginal(IntPoint point) { for (int p = 0; p < m_OriginalPolygon.Count; p++) { if (Clipper.PointInPolygon(point, m_OriginalPolygon[p]) != PointInPolygonResult.IsOutside) return true; } return false; } float ClosestPointOnSegment(IntPoint point, IntPoint s0, IntPoint s1) { double sX = s1.X - s0.X; double sY = s1.Y - s0.Y; var len2 = sX * sX + sY * sY; if (len2 < k_ClipperEpsilon) return 0; // degenerate segment double s0pX = point.X - s0.X; double s0pY = point.Y - s0.Y; var dot = s0pX * sX + s0pY * sY; return Mathf.Clamp01((float) (dot / len2)); } bool DoesIntersectOriginal(IntPoint l1, IntPoint l2) { for (int p = 0; p < m_OriginalPolygon.Count; ++p) { var original = m_OriginalPolygon[p]; var numPoints = original.Count; for (var i = 0; i < numPoints; ++i) if (FindIntersection(l1, l2, original[i], original[(i + 1) % numPoints]) == 2) return true; } return false; } } #if UNITY_EDITOR // Used by inspector to draw the baked path List> m_Vector2Path; public List> GetBakedPath() { // Convert to client space var numPaths = m_Solution.Count; if (m_Vector2Path == null) { m_Vector2Path = new List>(numPaths); for (int i = 0; i < numPaths; ++i) { var srcPoly = m_Solution[i]; int numPoints = srcPoly.Count; var pathSegment = new List(numPoints); for (var j = 0; j < numPoints; j++) { // Restore the original aspect ratio pathSegment.Add(m_AspectStretcher.Unstretch( new Vector2(srcPoly[j].X, srcPoly[j].Y) * k_IntToFloatScaler)); } m_Vector2Path.Add(pathSegment); } } return m_Vector2Path; } #endif static int FindIntersection(in IntPoint p1, in IntPoint p2, in IntPoint p3, in IntPoint p4) { // Get the segments' parameters. double dx12 = p2.X - p1.X; double dy12 = p2.Y - p1.Y; double dx34 = p4.X - p3.X; double dy34 = p4.Y - p3.Y; // Solve for t1 and t2 var denominator = dy12 * dx34 - dx12 * dy34; var t1 = ((p1.X - p3.X) * dy34 + (p3.Y - p1.Y) * dx34) / denominator; if (double.IsInfinity(t1) || double.IsNaN(t1)) { // The lines are parallel (or close enough to it). if (IntPointDiffSqrMagnitude(p1, p3) < k_ClipperEpsilon || IntPointDiffSqrMagnitude(p1, p4) < k_ClipperEpsilon || IntPointDiffSqrMagnitude(p2, p3) < k_ClipperEpsilon || IntPointDiffSqrMagnitude(p2, p4) < k_ClipperEpsilon) { return 2; // they are the same line, or very close parallels } return 0; // no intersection } var t2 = ((p3.X - p1.X) * dy12 + (p1.Y - p3.Y) * dx12) / -denominator; return (t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 < 1) ? 2 : 1; // 2 = segments intersect, 1 = lines intersect // local function double IntPointDiffSqrMagnitude(IntPoint point1, IntPoint point2) { double x = point1.X - point2.X; double y = point1.Y - point2.Y; return x * x + y * y; } } } readonly struct AspectStretcher { public float Aspect { get; } readonly float m_InverseAspect; readonly float m_CenterX; public AspectStretcher(float aspect, float centerX) { Aspect = aspect; m_InverseAspect = 1 / Aspect; m_CenterX = centerX; } public Vector2 Stretch(Vector2 p) => new((p.x - m_CenterX) * m_InverseAspect + m_CenterX, p.y); public Vector2 Unstretch(Vector2 p) => new((p.x - m_CenterX) * Aspect + m_CenterX, p.y); } float m_MinFrustumHeightWithBones; List> m_OriginalPolygon; IntPoint m_MidPoint; internal List> m_Skeleton = new(); const long k_FloatToIntScaler = 100000; const float k_IntToFloatScaler = 1f / k_FloatToIntScaler; const float k_MinStepSize = 500f / k_FloatToIntScaler; const int k_MitterLimit = 10; const int k_SkeletonPadding = 5; Rect m_PolygonRect; AspectStretcher m_AspectStretcher = new(1, 0); const float k_MaxComputationTimeForFullSkeletonBakeInSeconds = 5f; public ConfinerOven(in List> inputPath, in float aspectRatio, float maxFrustumHeight) { Initialize(inputPath, aspectRatio, maxFrustumHeight); } /// /// Converts and returns a prebaked ConfinerState for the input frustumHeight. /// public BakedSolution GetBakedSolution(float frustumHeight) { // If the user has set a max frustum height, respect it if (m_Cache.userSetMaxFrustumHeight > 0) frustumHeight = Mathf.Min(m_Cache.userSetMaxFrustumHeight, frustumHeight); // Special case: we are shrank to the mid point of the original input confiner area. if (State == BakingState.BAKED && frustumHeight >= m_Cache.theoreticalMaxFrustumHeight) return new BakedSolution(m_AspectStretcher.Aspect, frustumHeight, false, m_PolygonRect, m_OriginalPolygon, m_Cache.theoreticalMaxCandidate); // Inflate with clipper to frustumHeight var offsetter = new ClipperOffset(k_MitterLimit); offsetter.AddPaths(m_OriginalPolygon, JoinType.Miter, EndType.Polygon); var solution = offsetter.Execute(-1f * frustumHeight * k_FloatToIntScaler); if (solution.Count == 0) solution = m_Cache.theoreticalMaxCandidate; // Add in the skeleton var bakedSolution = new List>(); if (State == BakingState.BAKING || m_Skeleton.Count == 0) bakedSolution = solution; else { var c = new Clipper64(); c.AddSubject(solution); c.AddClip(m_Skeleton); c.Execute(ClipType.Union, FillRule.EvenOdd, bakedSolution); } return new BakedSolution( m_AspectStretcher.Aspect, frustumHeight, m_MinFrustumHeightWithBones < frustumHeight, m_PolygonRect, m_OriginalPolygon, bakedSolution); } struct PolygonSolution { public List> polygons; public float frustumHeight; public bool StateChanged(in List> paths) { if (paths.Count != polygons.Count) return true; for (var i = 0; i < paths.Count; ++i) { if (paths[i].Count != polygons[i].Count) return true; } return false; } public bool IsNull => polygons == null; } public enum BakingState { BAKING, BAKED, TIMEOUT } public BakingState State { get; private set; } public float bakeProgress; struct BakingStateCache { public ClipperOffset offsetter; public List solutions; public PolygonSolution rightCandidate; public PolygonSolution leftCandidate; public List> userSetMaxCandidate; public List> theoreticalMaxCandidate; // theoretical upper bound on the computation public float stepSize; public float maxFrustumHeight; public float userSetMaxFrustumHeight; public float theoreticalMaxFrustumHeight; public float currentFrustumHeight; public float bakeTime; } BakingStateCache m_Cache; void Initialize(in List> inputPath, in float aspectRatio, float maxFrustumHeight) { m_Skeleton.Clear(); m_Cache.userSetMaxFrustumHeight = maxFrustumHeight; m_MinFrustumHeightWithBones = float.MaxValue; m_PolygonRect = GetPolygonBoundingBox(inputPath); m_AspectStretcher = new AspectStretcher(aspectRatio, m_PolygonRect.center.x); // Don't compute further than what is the theoretical upper-bound (it may be a little bit more) m_Cache.theoreticalMaxFrustumHeight = Mathf.Max(m_PolygonRect.width / aspectRatio, m_PolygonRect.height) / 2f; // Initialize clipper m_OriginalPolygon = new List>(inputPath.Count); for (var i = 0; i < inputPath.Count; ++i) { var srcPath = inputPath[i]; var numPoints = srcPath.Count; var path = new List(numPoints); for (var j = 0; j < numPoints; ++j) { // Neutralize the aspect ratio var p = m_AspectStretcher.Stretch(srcPath[j]); path.Add(new IntPoint(p.x * k_FloatToIntScaler, p.y * k_FloatToIntScaler)); } m_OriginalPolygon.Add(path); } // calculate mid point and use it as the most shrank down version at theoretical max m_MidPoint = MidPointOfIntRect(Clipper.GetBounds(m_OriginalPolygon)); m_Cache.theoreticalMaxCandidate = new List> { new() { m_MidPoint } }; // Skip the expensive skeleton calculation if it's not wanted if (m_Cache.userSetMaxFrustumHeight < 0) { State = BakingState.BAKED; // if we don't need skeleton, then we don't need to bake return; } m_Cache.offsetter = new ClipperOffset(k_MitterLimit); m_Cache.offsetter.AddPaths(m_OriginalPolygon, JoinType.Miter, EndType.Polygon); // exact comparison to 0 is intentional! m_Cache.maxFrustumHeight = m_Cache.userSetMaxFrustumHeight; if (m_Cache.maxFrustumHeight == 0 || m_Cache.maxFrustumHeight > m_Cache.theoreticalMaxFrustumHeight) { m_Cache.maxFrustumHeight = m_Cache.theoreticalMaxFrustumHeight; m_Cache.userSetMaxCandidate = m_Cache.theoreticalMaxCandidate; } else { m_Cache.userSetMaxCandidate = new List>( m_Cache.offsetter.Execute(-1 * m_Cache.userSetMaxFrustumHeight * k_FloatToIntScaler)); if (m_Cache.userSetMaxCandidate.Count == 0) m_Cache.userSetMaxCandidate = m_Cache.theoreticalMaxCandidate; } m_Cache.stepSize = m_Cache.maxFrustumHeight; var solution = new List>(m_Cache.offsetter.Execute(0)); m_Cache.solutions = new List(); m_Cache.solutions.Add(new PolygonSolution { polygons = solution, frustumHeight = 0, }); m_Cache.rightCandidate = new PolygonSolution(); m_Cache.leftCandidate = new PolygonSolution { polygons = solution, frustumHeight = 0, }; m_Cache.currentFrustumHeight = 0; m_Cache.bakeTime = 0; State = BakingState.BAKING; bakeProgress = 0; // local functions Rect GetPolygonBoundingBox(in List> polygons) { float minX = float.PositiveInfinity, maxX = float.NegativeInfinity; float minY = float.PositiveInfinity, maxY = float.NegativeInfinity; for (var i = 0; i < polygons.Count; ++i) { for (var j = 0; j < polygons[i].Count; ++j) { var p = polygons[i][j]; minX = Mathf.Min(minX, p.x); maxX = Mathf.Max(maxX, p.x); minY = Mathf.Min(minY, p.y); maxY = Mathf.Max(maxY, p.y); } } return new Rect(minX, minY, Mathf.Max(0, maxX - minX), Mathf.Max(0, maxY - minY)); } IntPoint MidPointOfIntRect(IntRect bounds) => new((bounds.left + bounds.right) / 2, (bounds.top + bounds.bottom) / 2); } /// /// Creates shrinkable polygons from input parameters. /// The algorithm is divide and conquer. It iteratively shrinks down the input /// polygon towards its shrink directions. If the polygon intersects with itself, /// then we divide the polygon into two polygons at the intersection point, and /// continue the algorithm on these two polygons separately. We need to keep track of /// the connectivity information between sub-polygons. /// public void BakeConfiner(float maxComputationTimePerFrameInSeconds) { if (State != BakingState.BAKING) return; var startTime = Time.realtimeSinceStartup; // Binary search for state changes so we can compute the skeleton while (m_Cache.solutions.Count < 1000) { m_Cache.stepSize = Mathf.Min(m_Cache.stepSize, m_Cache.maxFrustumHeight - m_Cache.leftCandidate.frustumHeight); #if false Debug.Log($"States = {solutions.Count}, " + $"Frustum height = {currentFrustumHeight}, stepSize = {stepSize}"); #endif m_Cache.currentFrustumHeight = m_Cache.leftCandidate.frustumHeight + m_Cache.stepSize; var candidate = Math.Abs(m_Cache.currentFrustumHeight - m_Cache.maxFrustumHeight) < UnityVectorExtensions.Epsilon ? m_Cache.userSetMaxCandidate : m_Cache.offsetter.Execute(-1f * m_Cache.currentFrustumHeight * k_FloatToIntScaler); if (candidate.Count == 0) candidate = m_Cache.userSetMaxCandidate; if (m_Cache.leftCandidate.StateChanged(in candidate)) { m_Cache.rightCandidate = new PolygonSolution { polygons = new List>(candidate), frustumHeight = m_Cache.currentFrustumHeight, }; m_Cache.stepSize = Mathf.Max(m_Cache.stepSize / 2f, k_MinStepSize); } else { m_Cache.leftCandidate = new PolygonSolution { polygons = new List>(candidate), frustumHeight = m_Cache.currentFrustumHeight, }; // decrease stepSize if we have a right candidate if (!m_Cache.rightCandidate.IsNull) m_Cache.stepSize = Mathf.Max(m_Cache.stepSize / 2f, k_MinStepSize); } // if we have a right candidate, and left and right are sufficiently close, // then we have located a state change point if (!m_Cache.rightCandidate.IsNull && m_Cache.stepSize <= k_MinStepSize) { // Add both states: one before the state change and one after m_Cache.solutions.Add(m_Cache.leftCandidate); m_Cache.solutions.Add(m_Cache.rightCandidate); m_Cache.leftCandidate = m_Cache.rightCandidate; m_Cache.rightCandidate = new PolygonSolution(); // Back to max step m_Cache.stepSize = m_Cache.maxFrustumHeight; } else if (m_Cache.rightCandidate.IsNull || m_Cache.leftCandidate.frustumHeight >= m_Cache.maxFrustumHeight) { m_Cache.solutions.Add(m_Cache.leftCandidate); break; // stop searching, because we are at the bound } // Pause after max time per iteration reached var elapsedTime = Time.realtimeSinceStartup - startTime; if (elapsedTime > maxComputationTimePerFrameInSeconds) { m_Cache.bakeTime += elapsedTime; if (m_Cache.bakeTime > k_MaxComputationTimeForFullSkeletonBakeInSeconds) { State = BakingState.TIMEOUT; } bakeProgress = m_Cache.leftCandidate.frustumHeight / m_Cache.maxFrustumHeight; return; } } ComputeSkeleton(in m_Cache.solutions); // Remove useless/empty results for (var i = m_Cache.solutions.Count - 1; i >= 0; --i) if (m_Cache.solutions[i].polygons.Count == 0) m_Cache.solutions.RemoveAt(i); bakeProgress = 1; State = BakingState.BAKED; // local function // TODO: KGB: think about shrinking bones to a point to solve problem of seeing outside of the confiner area. void ComputeSkeleton(in List solutions) { var clipper = new Clipper64(); var offsetter = new ClipperOffset(k_MitterLimit); // At each state change point (1-2, 3-4, ...), collect geometry that gets lost over the transition for (var i = 1; i < solutions.Count - 1; i += 2) { var prev = solutions[i]; // solution before state change var next = solutions[i+1]; // solution after state change double step = k_SkeletonPadding * k_FloatToIntScaler * (next.frustumHeight - prev.frustumHeight); // Grow the larger polygon to inflate marginal regions offsetter.Clear(); offsetter.AddPaths(prev.polygons, JoinType.Miter, EndType.Polygon); var expandedPrev = new List>(offsetter.Execute(step)); // Grow the smaller polygon to be a bit bigger than the expanded larger one offsetter.Clear(); offsetter.AddPaths(next.polygons, JoinType.Miter, EndType.Polygon); var expandedNext = new List>(offsetter.Execute(step * 2)); // Compute the difference - this is the lost geometry var solution = new List>(); clipper.Clear(); clipper.AddSubject(expandedPrev); clipper.AddClip(expandedNext); clipper.Execute(ClipType.Difference, FillRule.EvenOdd, solution); // Add that lost geometry to the skeleton if (solution.Count > 0 && solution[0].Count > 0) { m_Skeleton.AddRange(solution); // Exact comparison is intentional if (m_MinFrustumHeightWithBones == float.MaxValue) m_MinFrustumHeightWithBones = next.frustumHeight; } } } } } } #endif