using System.Collections.Generic; using Pathfinding.Pooling; using Unity.Mathematics; using UnityEngine; using UnityEngine.Profiling; namespace Pathfinding { /// /// Simplifies a path on a grid graph using a string pulling algorithm. /// This is based on a paper called "Toward a String-Pulling Approach to Path Smoothing on Grid Graphs", /// with some optimizations as well as fixes for some edge cases that the paper didn't handle. /// /// The result is conceptually similar to the well known funnel string pulling algorithm for navmesh graphs /// but it uses a different algorithm. /// /// This class is used by the on grid graphs. /// /// See: /// See: /// See: article: https://ojs.aaai.org/index.php/SOCS/article/view/18541 /// public static class GridStringPulling { /// /// Z /// | /// | /// /// 3 2 /// \ | / /// -- - X - ----- X /// / | \ /// 0 1 /// /// | /// | /// static int2[] directionToCorners = new int2[] { new int2(0, 0), new int2(FixedPrecisionScale, 0), new int2(FixedPrecisionScale, FixedPrecisionScale), new int2(0, FixedPrecisionScale), }; static long Cross (int2 lhs, int2 rhs) { return (long)lhs.x*(long)rhs.y - (long)lhs.y*(long)rhs.x; } static long Dot (int2 a, int2 b) { return (long)a.x*(long)b.x + (long)a.y*(long)b.y; } static bool RightOrColinear (int2 a, int2 b, int2 p) { return (long)(b.x - a.x) * (long)(p.y - a.y) - (long)(p.x - a.x) * (long)(b.y - a.y) <= 0; } static int2 Perpendicular (int2 v) { return new int2(-v.y, v.x); } struct TriangleBounds { int2 d1, d2, d3; long t1, t2, t3; public TriangleBounds(int2 p1, int2 p2, int2 p3) { if (RightOrColinear(p1, p2, p3)) { var tmp = p3; p3 = p1; p1 = tmp; } d1 = Perpendicular(p2 - p1); d2 = Perpendicular(p3 - p2); d3 = Perpendicular(p1 - p3); t1 = Dot(d1, p1); t2 = Dot(d2, p2); t3 = Dot(d3, p3); } public bool Contains (int2 p) { return Dot(d1, p) >= t1 && Dot(d2, p) >= t2 && Dot(d3, p) >= t3; } } const int FixedPrecisionScale = 1024; static int2 ToFixedPrecision (Vector2 p) { return new int2(math.round(new float2(p)*FixedPrecisionScale)); } static Vector2 FromFixedPrecision (int2 p) { return (Vector2)(((float2)p) * (1.0f/FixedPrecisionScale)); } /// Returns which side of the line a - b that p lies on static Side Side2D (int2 a, int2 b, int2 p) { var s = Cross(b-a, p-a); return s > 0 ? Side.Left : (s < 0 ? Side.Right : Side.Colinear); } static Unity.Profiling.ProfilerMarker marker1 = new Unity.Profiling.ProfilerMarker("Linecast hit"); static Unity.Profiling.ProfilerMarker marker2 = new Unity.Profiling.ProfilerMarker("Linecast success"); static Unity.Profiling.ProfilerMarker marker3 = new Unity.Profiling.ProfilerMarker("Trace"); static Unity.Profiling.ProfilerMarker marker4 = new Unity.Profiling.ProfilerMarker("Neighbours"); static Unity.Profiling.ProfilerMarker marker5 = new Unity.Profiling.ProfilerMarker("Re-evaluate linecast"); static Unity.Profiling.ProfilerMarker marker6 = new Unity.Profiling.ProfilerMarker("Init"); static Unity.Profiling.ProfilerMarker marker7 = new Unity.Profiling.ProfilerMarker("Initloop"); /// /// Intersection length of the given segment with a square of size Int3.Precision centered at nodeCenter. /// The return value is between 0 and sqrt(2). /// public static float IntersectionLength (int2 nodeCenter, int2 segmentStart, int2 segmentEnd) { // TODO: Calculations can be hoisted var invNormal = math.rcp((float2)(segmentEnd - segmentStart)); var normalMagn = math.length((float2)(segmentEnd - segmentStart)); float tmin = float.NegativeInfinity, tmax = float.PositiveInfinity; var normal = segmentEnd - segmentStart; var bmin = nodeCenter; // - new int2(Int3.Precision/2, Int3.Precision/2); var bmax = nodeCenter + new int2(FixedPrecisionScale, FixedPrecisionScale); if (normal.x != 0.0) { float tx1 = (bmin.x - segmentStart.x)*invNormal.x; float tx2 = (bmax.x - segmentStart.x)*invNormal.x; tmin = math.max(tmin, math.min(tx1, tx2)); tmax = math.min(tmax, math.max(tx1, tx2)); } else if (segmentStart.x < bmin.x || segmentStart.x > bmax.x) { return 0.0f; } if (normal.y != 0.0) { float ty1 = (bmin.y - segmentStart.y)*invNormal.y; float ty2 = (bmax.y - segmentStart.y)*invNormal.y; tmin = math.max(tmin, math.min(ty1, ty2)); tmax = math.min(tmax, math.max(ty1, ty2)); } else if (segmentStart.y < bmin.y || segmentStart.y > bmax.y) { return 0.0f; } tmin = math.max(0, tmin); tmax = math.min(1, tmax); return math.max(tmax - tmin, 0)*normalMagn*(1.0f/FixedPrecisionScale); } internal static void TestIntersectionLength () { var s = FixedPrecisionScale; UnityEngine.Assertions.Assert.AreApproximatelyEqual(math.sqrt(2), IntersectionLength(new int2(1*s, 1*s), new int2(0, 0), new int2(2*s, 2*s))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(0.0f, IntersectionLength(new int2(1*s, 1*s), new int2(0, 0), new int2(0, 0))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(-1*s, s+1), new int2(2*s, s+1))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(2*s, s), new int2(-1*s, s))); // All sides of the square should be included UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(s, s), new int2(s+s, s))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(s+s, s), new int2(s+s, s+s))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(s+s, s+s), new int2(s, s+s))); UnityEngine.Assertions.Assert.AreApproximatelyEqual(1.0f, IntersectionLength(new int2(1*s, 1*s), new int2(s, s+s), new int2(s, s))); } /// /// Cost of moving across all the nodes in the list, along the given segment. /// It is assumed that the segment intersects the nodes. Any potentially intersecting nodes that are not part of the list will be ignored. /// static uint LinecastCost (List trace, int2 segmentStart, int2 segmentEnd, GridGraph gg, System.Func traversalCost) { // Check the cost of the segment compared to not taking this "shortcut" uint cost = 0; for (int k = 0; k < trace.Count; k++) { var node = trace[k] as GridNodeBase; // Note: this assumes the default grid connection costs are used. Which is relatively reasonable // since they require changing the code to modify. cost += (uint)(((float)traversalCost(node) + gg.nodeSize*Int3.Precision) * IntersectionLength(new int2(node.XCoordinateInGrid, node.ZCoordinateInGrid)*FixedPrecisionScale, segmentStart, segmentEnd)); } return cost; } enum PredicateFailMode { Undefined, Turn, LinecastObstacle, LinecastCost, ReachedEnd, } /// /// Simplifies a path on a grid graph using a string pulling algorithm. /// See the class documentation for more details. /// /// A list of input nodes. Only the slice of nodes from nodeStartIndex to nodeEndIndex (inclusive) will be used. These must all be of type GridNodeBase and must form a path (i.e. each node must be a neighbor to the next one in the list). /// The index in pathNodes to start from. /// The last index in pathNodes that is used. /// A more exact start point for the path. This should be a point inside the first node (if not, it will be clamped to the node's surface). /// A more exact end point for the path. This should be a point inside the first node (if not, it will be clamped to the node's surface). /// Can be used to specify how much it costs to traverse each node. If this is null, node penalties and tag penalties will be completely ignored. /// Can be used to filter out additional nodes that should be treated as unwalkable. It is assumed that all nodes in pathNodes pass this filter. /// If you only need the first N points of the result, you can specify that here, to avoid unnecessary work. public static List Calculate (List pathNodes, int nodeStartIndex, int nodeEndIndex, Vector3 startPoint, Vector3 endPoint, System.Func traversalCost = null, System.Func filter = null, int maxCorners = int.MaxValue) { Profiler.BeginSample("Funnel"); marker6.Begin(); // A list of indices into the arrays defined below. // Each index represents a point. But it's more convenient to use indices here and keep all the data separately (also probably faster). var outputPath = ListPool.Claim(); outputPath.Add(0); var numInputNodes = nodeEndIndex - nodeStartIndex + 1; var gg = pathNodes[nodeStartIndex].Graph as GridGraph; var trace = ListPool.Claim(); var turn = Side.Colinear; int counter = 0; // We will add two points, see comments inside the loop. // We may end up adding even more points later, therefore we get arrays that are a bit larger than we need for the initial path. numInputNodes += 2; int numPoints = numInputNodes; var nodes = ArrayPool.Claim(numPoints*2); var points = ArrayPool.Claim(numPoints*2); var normalizedPoints = ArrayPool.Claim(numPoints*2); var costs = ArrayPool.Claim(numPoints*2); marker7.Begin(); uint costSoFar = 0; // Go through all points and store all relevant data we need about them for (int j = 0; j < numInputNodes; j++) { // After the start-end modifier has adjusted the endpoints of the path, the line from the start point to the center of the second node in the path // might not actually have line of sight. // Assume the path starts at N1 with a diagonal move to node N2. // The start-end modifier adjusts the start point of the path to point S. // This causes the path to cut the corner to the unwalkable node in the bottom right. // ┌─────────┬────────┐ // │ │ │ // │ N2 │ │ // │ \ │ │ // │ \ │ │ // ├───────\─┼────────┤ // │########\│ │ // │#########│S N1 │ // │#########│ │ // │#########│ │ // └─────────┴────────┘ // We can solve this case by making the path go from S to N1 and then to N2 instead of directly from S to N2. // We also do the same thing for the end of the path. // The clamping and indexing here is essentially equivalent to one insert at the beginning of the arrays and one at the end. var node = nodes[j] = pathNodes[math.clamp(nodeStartIndex + j-1, nodeStartIndex, nodeEndIndex)] as GridNodeBase; var gridCoordinates = new int2(node.XCoordinateInGrid, node.ZCoordinateInGrid); var point = gridCoordinates * FixedPrecisionScale; int2 normalized; if (j == 0) { normalized = ToFixedPrecision(node.NormalizePoint(startPoint)); normalized = math.clamp(normalized, int2.zero, new int2(FixedPrecisionScale, FixedPrecisionScale)); } else if (j == numInputNodes - 1) { normalized = ToFixedPrecision(node.NormalizePoint(endPoint)); normalized = math.clamp(normalized, int2.zero, new int2(FixedPrecisionScale, FixedPrecisionScale)); } else { normalized = new int2(FixedPrecisionScale/2, FixedPrecisionScale/2); } points[j] = point + normalized; normalizedPoints[j] = normalized; if (j > 0 && traversalCost != null) { // Calculate the cost of moving along the original path costSoFar += (uint)(((float)traversalCost(nodes[j-1]) + gg.nodeSize*Int3.Precision) * IntersectionLength(new int2(nodes[j-1].XCoordinateInGrid, nodes[j-1].ZCoordinateInGrid)*FixedPrecisionScale, points[j-1], points[j])); costSoFar += (uint)(((float)traversalCost(nodes[j]) + gg.nodeSize*Int3.Precision) * IntersectionLength(gridCoordinates*FixedPrecisionScale, points[j-1], points[j])); } costs[j] = costSoFar; } marker7.End(); // We know that there is line of sight from the first point to the second point in the path. var lastSuccessfulStart = 0; var lastSuccessfulEnd = 1; marker6.End(); int i = 1; while (true) { if (i >= numInputNodes) { // We are done, add the last point outputPath.Add(numInputNodes-1); break; } if (outputPath.Count >= maxCorners) { // We are done with the partial result break; } counter++; if (counter > 10000) { Debug.LogError("Inf loop"); break; } // In the paper, they just use a straight forward loop over the input path. // However, it is better for performance to use a binary search to figure out the next time we need to do something. // We only need an 'i' which succeeds and 'i+1' which fails. // The success in this case is defined by the predicate below. We only need to do stuff if that returns true. var last = outputPath[outputPath.Count-1]; var normalizedLast = normalizedPoints[last]; var prev = outputPath.Count > 1 ? outputPath[outputPath.Count-2] : -1; var nodeLast = nodes[last]; var upperBound = numInputNodes - i - 1; // Lower and upper bounds for the binary search int mn = 0; // It is reasonable that most paths can be simplified at least a bit. Assume that seeing 4 or more nodes ahead is common. int mx = math.min(4, upperBound); var mxFailMode = PredicateFailMode.Undefined; uint mxLinecastCost = 0; // The calculations are not perfectly accurate. Allow the shortcut's cost to be a tiny bit higher. const uint COST_FUDGE = 5; GridHitInfo hit; // First fire off linecasts to nodes exponentially further away until the predicate returns true. while (true) { var idx = i + mx; var turnPredicate = outputPath.Count > 1 && Side2D(points[prev], points[last], points[idx]) != turn; if (turnPredicate) { mxFailMode = PredicateFailMode.Turn; break; } else { trace.Clear(); if (gg.Linecast(nodeLast, normalizedLast, nodes[idx], normalizedPoints[idx], out hit, trace, filter)) { mxFailMode = PredicateFailMode.LinecastObstacle; break; } else if (traversalCost != null) { var cost = LinecastCost(trace, points[last], points[idx], gg, traversalCost); if (cost > costs[idx] - costs[last] + COST_FUDGE) { // The "shortcut" had such a high penalty that it's not worth taking it mxFailMode = PredicateFailMode.LinecastCost; mxLinecastCost = cost; break; } } } if (mx < upperBound) { mn = mx; mx = math.min(mx*2, upperBound); } else { mxFailMode = PredicateFailMode.ReachedEnd; break; } } if (mxFailMode == PredicateFailMode.ReachedEnd) { // Reached final node without any hits, we can stop here outputPath.Add(numInputNodes-1); break; } // Run a standard binary search while (mx > mn + 1) { int mid = (mn+mx)/2; int idx = i + mid; var turnPredicate = outputPath.Count > 1 && Side2D(points[prev], points[last], points[idx]) != turn; bool pred = turnPredicate; if (turnPredicate) { mxFailMode = PredicateFailMode.Turn; } else { trace.Clear(); if (gg.Linecast(nodeLast, normalizedLast, nodes[idx], normalizedPoints[idx], out hit, trace, filter)) { mxFailMode = PredicateFailMode.LinecastObstacle; pred = true; } else if (traversalCost != null) { var cost = LinecastCost(trace, points[last], points[idx], gg, traversalCost); if (cost > costs[idx] - costs[last] + COST_FUDGE) { // The "shortcut" had such a high penalty that it's not worth taking it mxFailMode = PredicateFailMode.LinecastCost; mxLinecastCost = cost; pred = true; } } } if (pred) { mx = mid; } else { mn = mid; } } // i+mn is now a succeeding index, and i+mn+1 (or i+mx) is a failing index if (mn > 0) { lastSuccessfulStart = last; lastSuccessfulEnd = i+mn; } else { // We are not actually completely sure that i+mn is a succeeding index if mn=0 // So double check it. // TODO: This is a lot of code duplication. Tidy this up. var turnPredicate = outputPath.Count > 1 && Side2D(points[prev], points[last], points[i+mn]) != turn; bool pred = turnPredicate; if (turnPredicate) { } else { trace.Clear(); if (gg.Linecast(nodeLast, normalizedLast, nodes[i+mn], normalizedPoints[i+mn], out hit, trace, filter)) { pred = true; } else if (traversalCost != null) { var cost = LinecastCost(trace, points[last], points[i+mn], gg, traversalCost); if (cost > costs[i+mn] - costs[last] + COST_FUDGE) { // The "shortcut" had such a high penalty that it's not worth taking it mxLinecastCost = cost; pred = true; } } } if (!pred) { // Success! lastSuccessfulStart = last; lastSuccessfulEnd = i+mn; } } // Move to the failing index i += mx; UnityEngine.Assertions.Assert.AreNotEqual(mxFailMode, PredicateFailMode.Undefined); marker5.Begin(); trace.Clear(); trace.Clear(); if (mxFailMode == PredicateFailMode.LinecastCost) { outputPath.Add(lastSuccessfulEnd); turn = Side2D(points[last], points[lastSuccessfulEnd], points[i]); // It is guaranteed that there is line of sight from lastSuccessfulStart to lastSuccessfulEnd lastSuccessfulStart = lastSuccessfulEnd; i--; marker5.End(); continue; } else if (mxFailMode == PredicateFailMode.LinecastObstacle) { marker5.End(); // Draw.Line(nodes[last].UnNormalizePoint(FromFixedPrecision(normalizedPoints[last])), toNode.UnNormalizePoint(FromFixedPrecision(normalizedTo)), Color.red); marker1.Begin(); marker3.Begin(); // Re-run a previously successfully linecast to get all nodes it traversed. trace.Clear(); int chosenCorner; if (gg.Linecast(nodes[lastSuccessfulStart], normalizedPoints[lastSuccessfulStart], nodes[lastSuccessfulEnd], normalizedPoints[lastSuccessfulEnd], out hit, trace, filter)) { // Weird! This linecast should have succeeded. // Maybe the path crosses some unwalkable nodes it shouldn't cross (the graph could have changed). // Or possibly the linecast implementation doesn't handle some edge case (there are so many!) // In any case, we fall back to just assuming there is a valid line of sight. chosenCorner = lastSuccessfulEnd; Debug.LogError("Inconsistent linecasts"); } else { trace.Add(nodes[i]); marker3.End(); marker4.Begin(); GridNodeBase candidateNode = null; var candidateNormalizedPoint = new int2(); uint candidateCost = 0; var dirToCandidateCorner = new int2(); var lastSuccessfulStartPoint = points[lastSuccessfulStart]; var lastSuccessfulEndPoint = points[lastSuccessfulEnd]; var dir = lastSuccessfulEndPoint - lastSuccessfulStartPoint; var bounds = new TriangleBounds( lastSuccessfulStartPoint, lastSuccessfulEndPoint, points[i] ); var desiredSide = System.Math.Sign(Cross(dir, points[i] - lastSuccessfulStartPoint)); var candidateCostSoFar = costs[lastSuccessfulStart]; for (int j = 0; j < trace.Count; j++) { var node = trace[j] as GridNodeBase; var nodeGridPos = new int2(node.XCoordinateInGrid, node.ZCoordinateInGrid); var nodeCenter = nodeGridPos * FixedPrecisionScale; if (traversalCost != null) { // Not perfectly accurate as it doesn't measure the cost to the exact corner candidateCostSoFar += (uint)(((float)traversalCost(node) + gg.nodeSize*Int3.Precision) * IntersectionLength(nodeCenter, lastSuccessfulStartPoint, lastSuccessfulEndPoint)); } for (int d = 0; d < 4; d++) { if (!node.HasConnectionInDirection(d) || (filter != null && !filter(node.GetNeighbourAlongDirection(d)))) { for (int q = 0; q < 2; q++) { var ncorner = directionToCorners[(d+q)&0x3]; var corner = nodeCenter + ncorner; if (!bounds.Contains(corner)) { continue; } var dirToCorner = corner - lastSuccessfulStartPoint; // We shouldn't pick corners at our current position if (math.all(dirToCorner == 0)) continue; if (math.all(corner == lastSuccessfulEndPoint)) continue; var side = Cross(dirToCorner, dirToCandidateCorner); if (candidateNode == null || System.Math.Sign(side) == desiredSide || (side == 0 && math.lengthsq(dirToCorner) > math.lengthsq(dirToCandidateCorner))) { dirToCandidateCorner = dirToCorner; candidateNode = node; candidateNormalizedPoint = ncorner; candidateCost = candidateCostSoFar; } } } } } marker4.End(); if (candidateNode == null) { // Fall back to adding the lastSuccessfulEnd node. We know there's line of sight to that one. chosenCorner = lastSuccessfulEnd; } else { chosenCorner = numPoints; // TODO: Reallocate nodes[numPoints] = candidateNode; normalizedPoints[numPoints] = candidateNormalizedPoint; var gridCoordinates = new int2(candidateNode.XCoordinateInGrid, candidateNode.ZCoordinateInGrid); points[numPoints] = gridCoordinates * FixedPrecisionScale + candidateNormalizedPoint; costs[numPoints] = candidateCost; numPoints++; } } outputPath.Add(chosenCorner); turn = Side2D(points[last], points[chosenCorner], points[i]); // It is guaranteed that there is line of sight from lastSuccessfulStart to chosenCorner because of how we choose the corner. lastSuccessfulStart = chosenCorner; i--; marker1.End(); continue; } else { marker5.End(); marker2.Begin(); lastSuccessfulStart = last; lastSuccessfulEnd = i; // Draw.Line(nodes[last].UnNormalizePoint(FromFixedPrecision(normalizedPoints[last])), toNode.UnNormalizePoint(FromFixedPrecision(normalizedTo)), Color.green); if (outputPath.Count > 1) { var spPrev = outputPath[outputPath.Count-2]; var nextTurn = Side2D(points[spPrev], points[last], points[i]); // Check if the string is no longer taut. If it is not we can remove a previous point. if (turn != nextTurn) { // Draw.SphereOutline(nodes[pts[pts.Count-1]].UnNormalizePoint(FromFixedPrecision(normalizedPoints[pts[pts.Count-1]])), 0.05f, Color.black); lastSuccessfulStart = outputPath[outputPath.Count-2]; lastSuccessfulEnd = outputPath[outputPath.Count-1]; outputPath.RemoveAt(outputPath.Count-1); if (outputPath.Count > 1) { last = spPrev; spPrev = outputPath[outputPath.Count-2]; turn = Side2D(points[spPrev], points[last], points[i]); } else { // TODO: Should be separate value turn = Side.Colinear; } i--; marker2.End(); continue; } } marker2.End(); } } Profiler.EndSample(); var result = ListPool.Claim(outputPath.Count); for (int j = 0; j < outputPath.Count; j++) { var idx = outputPath[j]; result.Add(nodes[idx].UnNormalizePoint(FromFixedPrecision(normalizedPoints[idx]))); } ArrayPool.Release(ref nodes); ArrayPool.Release(ref points); ArrayPool.Release(ref normalizedPoints); ArrayPool.Release(ref costs); ListPool.Release(ref outputPath); ListPool.Release(ref trace); return result; } } }