From 332963a8bcf74a673b30a5fdabfd309e411760ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Mu=CC=88ller-Ho=CC=88hne?= Date: Tue, 14 Nov 2023 13:30:36 +0900 Subject: [PATCH 1/7] IncrementalBSplineBuilder: rewrite algorithm with many improvements - The BSpline builder now smoothens the input curve before processing it - Corners are detected explicitly based on local maxima of winding rate - The density of control points is also determined by winding rate --- .../TestSceneInteractivePathDrawing.cs | 8 +- .../Utils/IncrementalBSplineBuilder.cs | 326 ++++++++++++++---- osu.Framework/Utils/PathApproximator.cs | 4 +- 3 files changed, 268 insertions(+), 70 deletions(-) diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs index 4ebe43eca6..df0f3a2647 100644 --- a/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs @@ -59,17 +59,21 @@ public TestSceneInteractivePathDrawing() { bSplineBuilder.Degree = v; }); - AddSliderStep($"{nameof(bSplineBuilder.Tolerance)}", 0f, 1f, 0.1f, v => + AddSliderStep($"{nameof(bSplineBuilder.Tolerance)}", 0f, 3f, 1.5f, v => { bSplineBuilder.Tolerance = v; }); + AddSliderStep($"{nameof(bSplineBuilder.CornerThreshold)}", 0f, 1f, 0.4f, v => + { + bSplineBuilder.CornerThreshold = v; + }); } private void updateControlPointsViz() { controlPointViz.Clear(); - foreach (var cp in bSplineBuilder.GetControlPoints()) + foreach (var cp in bSplineBuilder.ControlPoints) { controlPointViz.Add(new Box { diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index 1f4b9eef39..b5f2149972 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -18,13 +18,78 @@ namespace osu.Framework.Utils public class IncrementalBSplineBuilder { private readonly List inputPath = new List(); + private readonly List cumulativeInputPathLength = new List(); + + private Vector2 getPathAt(List path, List cumulativeDistances, float t) + { + if (path.Count == 0) + throw new InvalidOperationException("Input path is empty."); + else if (path.Count == 1) + return path[0]; + + if (t <= 0) + return path[0]; + + if (t >= cumulativeDistances[^1]) + return path[^1]; + + int index = cumulativeDistances.BinarySearch(t); + if (index < 0) + index = ~index; + + float lengthBefore = index == 0 ? 0 : cumulativeDistances[index - 1]; + float lengthAfter = cumulativeDistances[index]; + float segmentLength = lengthAfter - lengthBefore; + float segmentT = (t - lengthBefore) / segmentLength; + + return Vector2.Lerp(path[index], path[index + 1], segmentT); + } + + private float inputPathLength => cumulativeInputPathLength.Count == 0 ? 0 : cumulativeInputPathLength[^1]; + + public const float FdEpsilon = PathApproximator.BEZIER_TOLERANCE * 8f; + + /// + /// Get the tangent at a given point on the path. + /// + /// The path to get the tangent from. + /// The cumulative distances of the path. + /// The point on the path to get the tangent from. + /// The tangent at the given point on the path. + private Vector2 getTangentAt(List path, List cumulativeDistances, float t) + { + Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); + Vector2 xplus = getPathAt(path, cumulativeDistances, t + FdEpsilon); + + return xplus == xminus ? Vector2.Zero : (xplus - xminus).Normalized(); + } + + /// + /// Get the amount of rotation (in radians) at a given point on the path. + /// + /// The path to get the rotation from. + /// The cumulative distances of the path. + /// The point on the path to get the rotation from. + /// The amount of rotation (in radians) at the given point on the path. + private float getWindingAt(List path, List cumulativeDistances, float t) + { + Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); + Vector2 x = getPathAt(path, cumulativeDistances, t); + Vector2 xplus = getPathAt(path, cumulativeDistances, t + FdEpsilon); + Vector2 tminus = x == xminus ? Vector2.Zero : (x - xminus).Normalized(); + Vector2 tplus = xplus == x ? Vector2.Zero : (xplus - x).Normalized(); + return MathF.Abs(MathF.Acos(Math.Clamp(Vector2.Dot(tminus, tplus), -1f, 1f))); + } private readonly Cached> outputCache = new Cached> { Value = new List() }; - private readonly List controlPoints = new List(); + private readonly Cached> controlPoints = new Cached> + { + Value = new List() + }; private int degree; @@ -42,13 +107,14 @@ public int Degree degree = value; outputCache.Invalidate(); + controlPoints.Invalidate(); } } private float tolerance; /// - /// Gets or sets the tolerance for determining when to add a new control point. Must not be negative. Default is 0.1. + /// Gets or sets the tolerance for determining when to add a new control point. Must not be negative. Default is 1.5. /// /// Thrown when the value is negative. public float Tolerance @@ -61,6 +127,27 @@ public float Tolerance tolerance = value; outputCache.Invalidate(); + controlPoints.Invalidate(); + } + } + + private float cornerThreshold; + + /// + /// Gets or sets the corner threshold for determining when to add a new control point. Must not be negative. Default is 0.1. + /// + /// Thrown when the value is negative. + public float CornerThreshold + { + get => cornerThreshold; + set + { + if (cornerThreshold < 0) + throw new ArgumentOutOfRangeException(nameof(value), "CornerThreshold must not be negative."); + + cornerThreshold = value; + outputCache.Invalidate(); + controlPoints.Invalidate(); } } @@ -77,44 +164,189 @@ public IReadOnlyList OutputPath return outputCache.Value; } } + /// + /// + /// The list of control points of the B-Spline. This is inferred from the input path. + /// + public IReadOnlyList ControlPoints + { + get + { + if (!controlPoints.IsValid) + regenerateApproximatedPathControlPoints(); + + return controlPoints.Value; + } + } /// /// Initializes a new instance of the class with specified degree and tolerance. /// /// The degree of the B-Spline. /// The tolerance for control point addition. - public IncrementalBSplineBuilder(int degree = 3, float tolerance = 0.1f) + public IncrementalBSplineBuilder(int degree = 3, float tolerance = 1.5f, float cornerThreshold = 0.4f) { Degree = degree; Tolerance = tolerance; + CornerThreshold = cornerThreshold; } - /// - /// The list of control points of the B-Spline. This is inferred from the input path. - /// - public IReadOnlyList GetControlPoints() - => controlPoints.ToArray(); - /// /// The list of input points. /// public IReadOnlyList GetInputPath() => inputPath.ToArray(); - private void redrawApproximatedPath() + /// + /// Computes a smoothed version of the input path by generating a high-degree BSpline from densely + /// spaces samples of the input path. + /// + /// A tuple containing the smoothed vertices and the cumulative distances of the smoothed path. + private (List vertices, List distances) computeSmoothedInputPath() { - // Set value of output cache to update the cache to be valid. - outputCache.Value = new List(); - if (inputPath.Count == 0) + var controlPoints = new Vector2[(int)(inputPathLength / FdEpsilon)]; + for (int i = 0; i < controlPoints.Length; ++i) + controlPoints[i] = getPathAt(inputPath, cumulativeInputPathLength, i * FdEpsilon); + + // Empirically, degree 7 works really well as a good tradeoff for smoothing vs sharpness here. + int smoothedInputPathDegree = 7; + var vertices = PathApproximator.BSplineToPiecewiseLinear(controlPoints, smoothedInputPathDegree); + var distances = new List(); + float cumulativeLength = 0; + for (int i = 1; i < vertices.Count; ++i) + { + cumulativeLength += Vector2.Distance(vertices[i], vertices[i - 1]); + distances.Add(cumulativeLength); + } + + return (vertices, distances); + } + + /// + /// Detects corners in the input path by thresholding how much the path curves and checking + /// whether this curvature is local (i.e. a corner rather than a smooth, yet tight turn). + /// + /// The vertices of the input path. + /// The cumulative distances of the input path. + /// A list of t values at which corners occur. + private List detectCorners(List vertices, List distances) + { + var corners = new List(); + var cornerT = new List { 0f }; + + float threshold = cornerThreshold / FdEpsilon; + + float stepSize = FdEpsilon; + int nSteps = (int)(distances[^1] / stepSize); + + // Empirically, averaging the winding rate over a neighborgood of 32 samples seems to be + // a good representation of the neighborhood of the curve. + const int nAvgSamples = 32; + float avgCurvature = 0.0f; + + for (int i = 0; i < nSteps; ++i) + { + // Update average curvature by adding the new winding rate and subtracting the old one from + // nAvgSamples steps ago. + float newt = i * stepSize; + float newWinding = MathF.Abs(getWindingAt(vertices, distances, newt)); + + float oldt = (i - nAvgSamples) * stepSize; + float oldWinding = oldt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, oldt)); + + avgCurvature = avgCurvature + (newWinding - oldWinding) / nAvgSamples; + + // Check whether the current winding rate is a local maximum and whether it exceeds the + // threshold as well as the surrounding average curvature. If so, we have found a corner. + // Also prohibit marking new corners that are too close to the previous one. + float midt = (i - nAvgSamples / 2f) * stepSize; + float midWinding = midt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, midt)); + + float distToPrevCorner = cornerT.Count == 0 ? float.MaxValue : newt - cornerT[^1]; + if (midWinding > threshold && midWinding > avgCurvature * 4 && distToPrevCorner > nAvgSamples * stepSize) + cornerT.Add(midt); + } + + // The end of the path is by definition a corner + cornerT.Add(distances[^1]); + return cornerT; + } + + private void regenerateApproximatedPathControlPoints() { + // Approximating a given input path with a BSpline has three stages: + // 1. Fit a dense-ish BSpline (with one control point in FdEpsilon-sized intervals) to the input path. + // The purpose of this dense BSpline is an initial smoothening that permits reliable curvature + // analysis in the next steps. + // 2. Detect corners by thresholding local curvature maxima and place sharp control points at these corners. + // 3. Place additional control points inbetween the sharp ones with density proportional to the product + // of Tolerance and curvature. + var (vertices, distances) = computeSmoothedInputPath(); + if (vertices.Count < 2) + { + controlPoints.Value = vertices; return; + } - var oldInputs = inputPath.ToList(); + controlPoints.Value = new List(); - inputPath.Clear(); - controlPoints.Clear(); + Debug.Assert(vertices.Count == distances.Count + 1); + var cornerTs = detectCorners(vertices, distances); + + var cps = controlPoints.Value; + cps.Add(vertices[0]); - foreach (var v in oldInputs) - AddLinearPoint(v); + // Populate each segment between corners with control points that have density proportional to the + // product of Tolerance and curvature. + float stepSize = FdEpsilon; + for (int i = 1; i < cornerTs.Count; ++i) + { + float totalAngle = 0; + + float t0 = cornerTs[i - 1] + stepSize * 2; + float t1 = cornerTs[i] - stepSize * 2; + + if (t1 > t0) + { + int nSteps = (int)((t1 - t0) / stepSize); + for (int j = 0; j < nSteps; ++j) + { + float t = t0 + j * stepSize; + totalAngle += getWindingAt(vertices, distances, t); + } + + int nControlPoints = (int)(totalAngle / Tolerance); + float controlPointSpacing = totalAngle / nControlPoints; + float currentAngle = 0; + for (int j = 0; j < nSteps; ++j) + { + float t = t0 + j * stepSize; + if (currentAngle > controlPointSpacing) + { + cps.Add(getPathAt(vertices, distances, t)); + currentAngle -= controlPointSpacing; + } + + currentAngle += getWindingAt(vertices, distances, t); + } + } + + // Insert the corner at the end of the segment as a sharp control point consisting of + // degree many regular control points, meaning that the BSpline will have a kink here. + // Special case the last corner which will be the end of the path and thus automatically + // duplicated degree times by BSplineToPiecewiseLinear down the line. + Vector2 corner = getPathAt(vertices, distances, cornerTs[i]); + if (i == cornerTs.Count - 1) { + cps.Add(corner); + } else { + for (int j = 0; j < degree; ++j) + cps.Add(corner); + } + } + } + + private void redrawApproximatedPath() + { + outputCache.Value = PathApproximator.BSplineToPiecewiseLinear(ControlPoints.ToArray(), degree); } /// @@ -123,7 +355,9 @@ private void redrawApproximatedPath() public void Clear() { inputPath.Clear(); - controlPoints.Clear(); + cumulativeInputPathLength.Clear(); + + controlPoints.Value = new List(); outputCache.Value = new List(); } @@ -133,61 +367,21 @@ public void Clear() /// The vector representing the point to add. public void AddLinearPoint(Vector2 v) { - if (!outputCache.IsValid) - redrawApproximatedPath(); + outputCache.Invalidate(); + controlPoints.Invalidate(); if (inputPath.Count == 0) { inputPath.Add(v); - // We add the first point twice so we can track the - // end point of the raw path using the last control point. - controlPoints.Add(inputPath[0]); - controlPoints.Add(inputPath[0]); return; } - inputPath.Add(v); - - var cps = controlPoints; - Debug.Assert(cps.Count >= 2); - - cps[^1] = inputPath[^1]; - - // Calculate the normalized momentum vectors for both raw and approximated paths. - // Momentum here refers to a direction vector representing the path's direction of movement. - var mraw = momentumDirection(inputPath, 3); - var mcp = cps.Count > 2 ? momentumDirection(outputCache.Value, 1) : Vector2.Zero; - - // Determine the alignment between the raw path and control path's momentums. - // It uses Vector2.Dot which calculates the cosine of the angle between two vectors. - // This alignment is used to adjust the control points based on the path's direction change. - float alignment = mraw == Vector2.Zero || mcp == Vector2.Zero ? 1.0f : MathF.Max(Vector2.Dot(mraw, mcp), 0.01f); - - // Calculate the distance between the last two control points. - // This distance is then used, along with alignment, to decide if a new control point is needed. - // The threshold for adding a new control point is based on the alignment and a predefined accuracy factor. - float distance = Vector2.Distance(cps[^1], cps[^2]); - if (distance / MathF.Pow(alignment, 4) > Tolerance * 1000) - cps.Add(cps[^1]); - - outputCache.Value = PathApproximator.BSplineToPiecewiseLinear(cps.ToArray(), degree); - } - - private Vector2 momentumDirection(IReadOnlyList vertices, int window) - { - if (vertices.Count < window + 1) - return Vector2.Zero; - - var sum = Vector2.Zero; - for (int i = 0; i < window; i++) - sum += vertices[^(i + 1)] - vertices[^(i + 2)]; - - // We choose a very small acceptable difference here to ensure that - // small momenta are not ignored. - if (Precision.AlmostEquals(sum.X, 0, 1e-7f) && Precision.AlmostEquals(sum.Y, 0, 1e-7f)) - return Vector2.Zero; + float inputDistance = Vector2.Distance(v, inputPath[^1]); + if (inputDistance < FdEpsilon) + return; - return (sum / window).Normalized(); + inputPath.Add(v); + cumulativeInputPathLength.Add((cumulativeInputPathLength.Count == 0 ? 0 : cumulativeInputPathLength[^1]) + inputDistance); } } } diff --git a/osu.Framework/Utils/PathApproximator.cs b/osu.Framework/Utils/PathApproximator.cs index a0a3958662..6298fad8cf 100644 --- a/osu.Framework/Utils/PathApproximator.cs +++ b/osu.Framework/Utils/PathApproximator.cs @@ -14,7 +14,7 @@ namespace osu.Framework.Utils /// public static class PathApproximator { - private const float bezier_tolerance = 0.25f; + public const float BEZIER_TOLERANCE = 0.25f; /// /// The amount of pieces to calculate for each control point quadruplet. @@ -313,7 +313,7 @@ private static bool bezierIsFlatEnough(Vector2[] controlPoints) { for (int i = 1; i < controlPoints.Length - 1; i++) { - if ((controlPoints[i - 1] - 2 * controlPoints[i] + controlPoints[i + 1]).LengthSquared > bezier_tolerance * bezier_tolerance * 4) + if ((controlPoints[i - 1] - 2 * controlPoints[i] + controlPoints[i + 1]).LengthSquared > BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4) return false; } From 72490b82779ccbef7abfde63774c66e533256f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Mu=CC=88ller-Ho=CC=88hne?= Date: Tue, 14 Nov 2023 17:19:40 +0900 Subject: [PATCH 2/7] Add special casing for perfectly linear segments --- .../Utils/IncrementalBSplineBuilder.cs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index b5f2149972..5eda9624c2 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Caching; +using osu.Framework.Graphics.Primitives; using osuTK; namespace osu.Framework.Utils @@ -20,7 +21,7 @@ public class IncrementalBSplineBuilder private readonly List inputPath = new List(); private readonly List cumulativeInputPathLength = new List(); - private Vector2 getPathAt(List path, List cumulativeDistances, float t) + private static Vector2 getPathAt(List path, List cumulativeDistances, float t) { if (path.Count == 0) throw new InvalidOperationException("Input path is empty."); @@ -56,7 +57,7 @@ private Vector2 getPathAt(List path, List cumulativeDistances, f /// The cumulative distances of the path. /// The point on the path to get the tangent from. /// The tangent at the given point on the path. - private Vector2 getTangentAt(List path, List cumulativeDistances, float t) + private static Vector2 getTangentAt(List path, List cumulativeDistances, float t) { Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); Vector2 xplus = getPathAt(path, cumulativeDistances, t + FdEpsilon); @@ -71,7 +72,7 @@ private Vector2 getTangentAt(List path, List cumulativeDistances /// The cumulative distances of the path. /// The point on the path to get the rotation from. /// The amount of rotation (in radians) at the given point on the path. - private float getWindingAt(List path, List cumulativeDistances, float t) + private static float getWindingAt(List path, List cumulativeDistances, float t) { Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); Vector2 x = getPathAt(path, cumulativeDistances, t); @@ -272,7 +273,8 @@ private List detectCorners(List vertices, List distances) return cornerT; } - private void regenerateApproximatedPathControlPoints() { + private void regenerateApproximatedPathControlPoints() + { // Approximating a given input path with a BSpline has three stages: // 1. Fit a dense-ish BSpline (with one control point in FdEpsilon-sized intervals) to the input path. // The purpose of this dense BSpline is an initial smoothening that permits reliable curvature @@ -305,6 +307,14 @@ private void regenerateApproximatedPathControlPoints() { float t0 = cornerTs[i - 1] + stepSize * 2; float t1 = cornerTs[i] - stepSize * 2; + Vector2 c0 = getPathAt(vertices, distances, cornerTs[i - 1]); + Vector2 c1 = getPathAt(vertices, distances, cornerTs[i]); + Line linearConnection = new Line(c0, c1); + + var tmp = new List(); + bool allOnLine = true; + float onLineThreshold = 10 * stepSize; + if (t1 > t0) { int nSteps = (int)((t1 - t0) / stepSize); @@ -322,7 +332,11 @@ private void regenerateApproximatedPathControlPoints() { float t = t0 + j * stepSize; if (currentAngle > controlPointSpacing) { - cps.Add(getPathAt(vertices, distances, t)); + Vector2 p = getPathAt(vertices, distances, t); + if (linearConnection.DistanceSquaredToPoint(p) > onLineThreshold * onLineThreshold) + allOnLine = false; + + tmp.Add(p); currentAngle -= controlPointSpacing; } @@ -330,17 +344,18 @@ private void regenerateApproximatedPathControlPoints() { } } + if (!allOnLine) + cps.AddRange(tmp); + // Insert the corner at the end of the segment as a sharp control point consisting of // degree many regular control points, meaning that the BSpline will have a kink here. // Special case the last corner which will be the end of the path and thus automatically // duplicated degree times by BSplineToPiecewiseLinear down the line. - Vector2 corner = getPathAt(vertices, distances, cornerTs[i]); - if (i == cornerTs.Count - 1) { - cps.Add(corner); - } else { + if (i == cornerTs.Count - 1) + cps.Add(c1); + else for (int j = 0; j < degree; ++j) - cps.Add(corner); - } + cps.Add(c1); } } @@ -377,7 +392,7 @@ public void AddLinearPoint(Vector2 v) } float inputDistance = Vector2.Distance(v, inputPath[^1]); - if (inputDistance < FdEpsilon) + if (inputDistance < FdEpsilon * 2) return; inputPath.Add(v); From ed76cdbec40e0605c86853b7b4e139e172db2784 Mon Sep 17 00:00:00 2001 From: cs Date: Wed, 15 Nov 2023 05:30:59 +0100 Subject: [PATCH 3/7] CI fixes and code cleanup --- .../Utils/IncrementalBSplineBuilder.cs | 84 +++++++++---------- osu.Framework/Utils/PathApproximator.cs | 2 +- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index 5eda9624c2..469cde9b62 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -48,22 +48,7 @@ private static Vector2 getPathAt(List path, List cumulativeDista private float inputPathLength => cumulativeInputPathLength.Count == 0 ? 0 : cumulativeInputPathLength[^1]; - public const float FdEpsilon = PathApproximator.BEZIER_TOLERANCE * 8f; - - /// - /// Get the tangent at a given point on the path. - /// - /// The path to get the tangent from. - /// The cumulative distances of the path. - /// The point on the path to get the tangent from. - /// The tangent at the given point on the path. - private static Vector2 getTangentAt(List path, List cumulativeDistances, float t) - { - Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); - Vector2 xplus = getPathAt(path, cumulativeDistances, t + FdEpsilon); - - return xplus == xminus ? Vector2.Zero : (xplus - xminus).Normalized(); - } + public const float FD_EPSILON = PathApproximator.BEZIER_TOLERANCE * 8f; /// /// Get the amount of rotation (in radians) at a given point on the path. @@ -74,9 +59,9 @@ private static Vector2 getTangentAt(List path, List cumulativeDi /// The amount of rotation (in radians) at the given point on the path. private static float getWindingAt(List path, List cumulativeDistances, float t) { - Vector2 xminus = getPathAt(path, cumulativeDistances, t - FdEpsilon); + Vector2 xminus = getPathAt(path, cumulativeDistances, t - FD_EPSILON); Vector2 x = getPathAt(path, cumulativeDistances, t); - Vector2 xplus = getPathAt(path, cumulativeDistances, t + FdEpsilon); + Vector2 xplus = getPathAt(path, cumulativeDistances, t + FD_EPSILON); Vector2 tminus = x == xminus ? Vector2.Zero : (x - xminus).Normalized(); Vector2 tplus = xplus == x ? Vector2.Zero : (xplus - x).Normalized(); return MathF.Abs(MathF.Acos(Math.Clamp(Vector2.Dot(tminus, tplus), -1f, 1f))); @@ -135,7 +120,7 @@ public float Tolerance private float cornerThreshold; /// - /// Gets or sets the corner threshold for determining when to add a new control point. Must not be negative. Default is 0.1. + /// Gets or sets the corner threshold for determining when to add a new control point. Must not be negative. Default is 0.4. /// /// Thrown when the value is negative. public float CornerThreshold @@ -165,7 +150,7 @@ public IReadOnlyList OutputPath return outputCache.Value; } } - /// + /// /// The list of control points of the B-Spline. This is inferred from the input path. /// @@ -185,6 +170,7 @@ public IReadOnlyList ControlPoints /// /// The degree of the B-Spline. /// The tolerance for control point addition. + /// The threshold to use for inserting sharp control points at corners. public IncrementalBSplineBuilder(int degree = 3, float tolerance = 1.5f, float cornerThreshold = 0.4f) { Degree = degree; @@ -205,15 +191,16 @@ public IReadOnlyList GetInputPath() /// A tuple containing the smoothed vertices and the cumulative distances of the smoothed path. private (List vertices, List distances) computeSmoothedInputPath() { - var controlPoints = new Vector2[(int)(inputPathLength / FdEpsilon)]; - for (int i = 0; i < controlPoints.Length; ++i) - controlPoints[i] = getPathAt(inputPath, cumulativeInputPathLength, i * FdEpsilon); + var cps = new Vector2[(int)(inputPathLength / FD_EPSILON)]; + for (int i = 0; i < cps.Length; ++i) + cps[i] = getPathAt(inputPath, cumulativeInputPathLength, i * FD_EPSILON); // Empirically, degree 7 works really well as a good tradeoff for smoothing vs sharpness here. - int smoothedInputPathDegree = 7; - var vertices = PathApproximator.BSplineToPiecewiseLinear(controlPoints, smoothedInputPathDegree); + const int smoothed_input_path_degree = 7; + var vertices = PathApproximator.BSplineToPiecewiseLinear(cps, smoothed_input_path_degree); var distances = new List(); float cumulativeLength = 0; + for (int i = 1; i < vertices.Count; ++i) { cumulativeLength += Vector2.Distance(vertices[i], vertices[i - 1]); @@ -232,39 +219,38 @@ public IReadOnlyList GetInputPath() /// A list of t values at which corners occur. private List detectCorners(List vertices, List distances) { - var corners = new List(); var cornerT = new List { 0f }; - float threshold = cornerThreshold / FdEpsilon; + float threshold = cornerThreshold / FD_EPSILON; - float stepSize = FdEpsilon; - int nSteps = (int)(distances[^1] / stepSize); + const float step_size = FD_EPSILON; + int nSteps = (int)(distances[^1] / step_size); // Empirically, averaging the winding rate over a neighborgood of 32 samples seems to be // a good representation of the neighborhood of the curve. - const int nAvgSamples = 32; + const int n_avg_samples = 32; float avgCurvature = 0.0f; for (int i = 0; i < nSteps; ++i) { // Update average curvature by adding the new winding rate and subtracting the old one from // nAvgSamples steps ago. - float newt = i * stepSize; + float newt = i * step_size; float newWinding = MathF.Abs(getWindingAt(vertices, distances, newt)); - float oldt = (i - nAvgSamples) * stepSize; + float oldt = (i - n_avg_samples) * step_size; float oldWinding = oldt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, oldt)); - avgCurvature = avgCurvature + (newWinding - oldWinding) / nAvgSamples; + avgCurvature += (newWinding - oldWinding) / n_avg_samples; // Check whether the current winding rate is a local maximum and whether it exceeds the // threshold as well as the surrounding average curvature. If so, we have found a corner. // Also prohibit marking new corners that are too close to the previous one. - float midt = (i - nAvgSamples / 2f) * stepSize; + float midt = (i - n_avg_samples / 2f) * step_size; float midWinding = midt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, midt)); float distToPrevCorner = cornerT.Count == 0 ? float.MaxValue : newt - cornerT[^1]; - if (midWinding > threshold && midWinding > avgCurvature * 4 && distToPrevCorner > nAvgSamples * stepSize) + if (midWinding > threshold && midWinding > avgCurvature * 4 && distToPrevCorner > n_avg_samples * step_size) cornerT.Add(midt); } @@ -282,7 +268,10 @@ private void regenerateApproximatedPathControlPoints() // 2. Detect corners by thresholding local curvature maxima and place sharp control points at these corners. // 3. Place additional control points inbetween the sharp ones with density proportional to the product // of Tolerance and curvature. + // 4. Additionally, we special case linear segments: if the path does not deviate more + // than some threshold from a straight line, we do not add additional control points. var (vertices, distances) = computeSmoothedInputPath(); + if (vertices.Count < 2) { controlPoints.Value = vertices; @@ -299,13 +288,14 @@ private void regenerateApproximatedPathControlPoints() // Populate each segment between corners with control points that have density proportional to the // product of Tolerance and curvature. - float stepSize = FdEpsilon; + const float step_size = FD_EPSILON; + for (int i = 1; i < cornerTs.Count; ++i) { float totalAngle = 0; - float t0 = cornerTs[i - 1] + stepSize * 2; - float t1 = cornerTs[i] - stepSize * 2; + float t0 = cornerTs[i - 1] + step_size * 2; + float t1 = cornerTs[i] - step_size * 2; Vector2 c0 = getPathAt(vertices, distances, cornerTs[i - 1]); Vector2 c1 = getPathAt(vertices, distances, cornerTs[i]); @@ -313,27 +303,30 @@ private void regenerateApproximatedPathControlPoints() var tmp = new List(); bool allOnLine = true; - float onLineThreshold = 10 * stepSize; + const float on_line_threshold = 10 * step_size; if (t1 > t0) { - int nSteps = (int)((t1 - t0) / stepSize); + int nSteps = (int)((t1 - t0) / step_size); + for (int j = 0; j < nSteps; ++j) { - float t = t0 + j * stepSize; + float t = t0 + j * step_size; totalAngle += getWindingAt(vertices, distances, t); } int nControlPoints = (int)(totalAngle / Tolerance); float controlPointSpacing = totalAngle / nControlPoints; float currentAngle = 0; + for (int j = 0; j < nSteps; ++j) { - float t = t0 + j * stepSize; + float t = t0 + j * step_size; + if (currentAngle > controlPointSpacing) { Vector2 p = getPathAt(vertices, distances, t); - if (linearConnection.DistanceSquaredToPoint(p) > onLineThreshold * onLineThreshold) + if (linearConnection.DistanceSquaredToPoint(p) > on_line_threshold * on_line_threshold) allOnLine = false; tmp.Add(p); @@ -354,8 +347,7 @@ private void regenerateApproximatedPathControlPoints() if (i == cornerTs.Count - 1) cps.Add(c1); else - for (int j = 0; j < degree; ++j) - cps.Add(c1); + cps.AddRange(Enumerable.Repeat(c1, degree)); } } @@ -392,7 +384,7 @@ public void AddLinearPoint(Vector2 v) } float inputDistance = Vector2.Distance(v, inputPath[^1]); - if (inputDistance < FdEpsilon * 2) + if (inputDistance < FD_EPSILON * 2) return; inputPath.Add(v); diff --git a/osu.Framework/Utils/PathApproximator.cs b/osu.Framework/Utils/PathApproximator.cs index 6298fad8cf..5c3271c390 100644 --- a/osu.Framework/Utils/PathApproximator.cs +++ b/osu.Framework/Utils/PathApproximator.cs @@ -31,7 +31,7 @@ public static class PathApproximator /// A list of vectors representing the piecewise-linear approximation. public static List BezierToPiecewiseLinear(ReadOnlySpan controlPoints) { - return BSplineToPiecewiseLinear(controlPoints, controlPoints.Length - 1); + return BSplineToPiecewiseLinear(controlPoints, Math.Max(1, controlPoints.Length - 1)); } /// From b35665a94f95b431a7c3fa9d9f57ccb62d905efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Mu=CC=88ller-Ho=CC=88hne?= Date: Thu, 16 Nov 2023 14:47:56 +0900 Subject: [PATCH 4/7] Address code review --- .../Utils/IncrementalBSplineBuilder.cs | 39 +++++++++++-------- osu.Framework/Utils/PathApproximator.cs | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index 469cde9b62..1fe8e6bb7c 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -48,16 +48,19 @@ private static Vector2 getPathAt(List path, List cumulativeDista private float inputPathLength => cumulativeInputPathLength.Count == 0 ? 0 : cumulativeInputPathLength[^1]; - public const float FD_EPSILON = PathApproximator.BEZIER_TOLERANCE * 8f; + /// + /// Spacing to use in spline-related finite difference (FD) calculations. + /// + internal const float FD_EPSILON = PathApproximator.BEZIER_TOLERANCE * 8f; /// - /// Get the amount of rotation (in radians) at a given point on the path. + /// Get the absolute amount of rotation (in radians) at a given point on the path. /// /// The path to get the rotation from. /// The cumulative distances of the path. /// The point on the path to get the rotation from. - /// The amount of rotation (in radians) at the given point on the path. - private static float getWindingAt(List path, List cumulativeDistances, float t) + /// The absolute amount of rotation (in radians) at the given point on the path. + private static float getAbsWindingAt(List path, List cumulativeDistances, float t) { Vector2 xminus = getPathAt(path, cumulativeDistances, t - FD_EPSILON); Vector2 x = getPathAt(path, cumulativeDistances, t); @@ -226,7 +229,7 @@ private List detectCorners(List vertices, List distances) const float step_size = FD_EPSILON; int nSteps = (int)(distances[^1] / step_size); - // Empirically, averaging the winding rate over a neighborgood of 32 samples seems to be + // Empirically, averaging the winding rate over a neighborhood of 32 samples seems to be // a good representation of the neighborhood of the curve. const int n_avg_samples = 32; float avgCurvature = 0.0f; @@ -236,18 +239,20 @@ private List detectCorners(List vertices, List distances) // Update average curvature by adding the new winding rate and subtracting the old one from // nAvgSamples steps ago. float newt = i * step_size; - float newWinding = MathF.Abs(getWindingAt(vertices, distances, newt)); + float newWinding = getAbsWindingAt(vertices, distances, newt); float oldt = (i - n_avg_samples) * step_size; - float oldWinding = oldt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, oldt)); + float oldWinding = oldt < 0 ? 0 : getAbsWindingAt(vertices, distances, oldt); avgCurvature += (newWinding - oldWinding) / n_avg_samples; // Check whether the current winding rate is a local maximum and whether it exceeds the // threshold as well as the surrounding average curvature. If so, we have found a corner. - // Also prohibit marking new corners that are too close to the previous one. + // Also prohibit marking new corners that are too close to the previous one, where "too close" + // is defined as the averaging windows overlapping. This ensures the same corner can not + // be detected twice. float midt = (i - n_avg_samples / 2f) * step_size; - float midWinding = midt < 0 ? 0 : MathF.Abs(getWindingAt(vertices, distances, midt)); + float midWinding = midt < 0 ? 0 : getAbsWindingAt(vertices, distances, midt); float distToPrevCorner = cornerT.Count == 0 ? float.MaxValue : newt - cornerT[^1]; if (midWinding > threshold && midWinding > avgCurvature * 4 && distToPrevCorner > n_avg_samples * step_size) @@ -292,7 +297,7 @@ private void regenerateApproximatedPathControlPoints() for (int i = 1; i < cornerTs.Count; ++i) { - float totalAngle = 0; + float totalWinding = 0; float t0 = cornerTs[i - 1] + step_size * 2; float t1 = cornerTs[i] - step_size * 2; @@ -312,28 +317,28 @@ private void regenerateApproximatedPathControlPoints() for (int j = 0; j < nSteps; ++j) { float t = t0 + j * step_size; - totalAngle += getWindingAt(vertices, distances, t); + totalWinding += getAbsWindingAt(vertices, distances, t); } - int nControlPoints = (int)(totalAngle / Tolerance); - float controlPointSpacing = totalAngle / nControlPoints; - float currentAngle = 0; + int nControlPoints = (int)(totalWinding / Tolerance); + float controlPointSpacing = totalWinding / nControlPoints; + float currentWinding = 0; for (int j = 0; j < nSteps; ++j) { float t = t0 + j * step_size; - if (currentAngle > controlPointSpacing) + if (currentWinding > controlPointSpacing) { Vector2 p = getPathAt(vertices, distances, t); if (linearConnection.DistanceSquaredToPoint(p) > on_line_threshold * on_line_threshold) allOnLine = false; tmp.Add(p); - currentAngle -= controlPointSpacing; + currentWinding -= controlPointSpacing; } - currentAngle += getWindingAt(vertices, distances, t); + currentWinding += getAbsWindingAt(vertices, distances, t); } } diff --git a/osu.Framework/Utils/PathApproximator.cs b/osu.Framework/Utils/PathApproximator.cs index 0f68de93c7..887572007a 100644 --- a/osu.Framework/Utils/PathApproximator.cs +++ b/osu.Framework/Utils/PathApproximator.cs @@ -14,7 +14,7 @@ namespace osu.Framework.Utils /// public static class PathApproximator { - public const float BEZIER_TOLERANCE = 0.25f; + internal const float BEZIER_TOLERANCE = 0.25f; /// /// The amount of pieces to calculate for each control point quadruplet. From 8112e93430201d622251ce3f730bc7349ad5c35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Mu=CC=88ller-Ho=CC=88hne?= Date: Thu, 16 Nov 2023 15:09:02 +0900 Subject: [PATCH 5/7] Make linear special case threshold depend on Tolerance --- osu.Framework/Utils/IncrementalBSplineBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index 1fe8e6bb7c..4a3b1b06a7 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -308,7 +308,7 @@ private void regenerateApproximatedPathControlPoints() var tmp = new List(); bool allOnLine = true; - const float on_line_threshold = 10 * step_size; + float on_line_threshold = 5 * Tolerance * step_size; if (t1 > t0) { From d4808ee3b04dce612a9a7f199cfcc2fdda5ec1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 16 Nov 2023 15:01:35 +0900 Subject: [PATCH 6/7] Add testing for B-spline edge cases --- .../MathUtils/TestPathApproximator.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/osu.Framework.Tests/MathUtils/TestPathApproximator.cs b/osu.Framework.Tests/MathUtils/TestPathApproximator.cs index f545f42a29..8b32b06d78 100644 --- a/osu.Framework.Tests/MathUtils/TestPathApproximator.cs +++ b/osu.Framework.Tests/MathUtils/TestPathApproximator.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Utils; using osuTK; @@ -40,5 +42,24 @@ public void TestBSpline() Assert.True(Precision.AlmostEquals(approximated[28], points[6], 1e-4f)); Assert.True(Precision.AlmostEquals(approximated[10], new Vector2(-0.11415f, -0.69065f), 1e-4f)); } + + [Test] + public void TestBSplineThrowsOnInvalidDegree() + { + Vector2[] points = { new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, -1), new Vector2(-1, -1), new Vector2(-1, 1), new Vector2(3, 2), new Vector2(3, 0) }; + + Assert.Throws(() => PathApproximator.BSplineToPiecewiseLinear(points, 0)); + Assert.Throws(() => PathApproximator.BSplineToPiecewiseLinear(points, -5)); + } + + [TestCase(0)] + [TestCase(1)] + public void TestBSplineDoesNothingWhenGivenTooFewPoints(int pointCount) + { + var points = Enumerable.Repeat(new Vector2(), pointCount).ToArray(); + + Assert.That(PathApproximator.BSplineToPiecewiseLinear(points, 3), Is.EquivalentTo(points)); + Assert.That(PathApproximator.BezierToPiecewiseLinear(points), Is.EquivalentTo(points)); + } } } From 865344dbf53007da46690f2ca8d27a35105f60ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 16 Nov 2023 15:17:01 +0900 Subject: [PATCH 7/7] Rename variable to match code style --- osu.Framework/Utils/IncrementalBSplineBuilder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs index 4a3b1b06a7..fbab87371b 100644 --- a/osu.Framework/Utils/IncrementalBSplineBuilder.cs +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -308,7 +308,7 @@ private void regenerateApproximatedPathControlPoints() var tmp = new List(); bool allOnLine = true; - float on_line_threshold = 5 * Tolerance * step_size; + float onLineThreshold = 5 * Tolerance * step_size; if (t1 > t0) { @@ -331,7 +331,7 @@ private void regenerateApproximatedPathControlPoints() if (currentWinding > controlPointSpacing) { Vector2 p = getPathAt(vertices, distances, t); - if (linearConnection.DistanceSquaredToPoint(p) > on_line_threshold * on_line_threshold) + if (linearConnection.DistanceSquaredToPoint(p) > onLineThreshold * onLineThreshold) allOnLine = false; tmp.Add(p);