Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add method to convert piecewise linear curves to spline control points #6056

Merged
merged 37 commits into from
Dec 7, 2023

Conversation

OliBomby
Copy link
Contributor

@OliBomby OliBomby commented Nov 20, 2023

Summary

This PR introduces an algorithm for approximating arbitrary piecewise linear paths with B-splines or Bezier curves. The method PiecewiseLinearToBSpline is then used to improve the paths generated by IncrementalBSplineBuilder, making the curve accurately follow the input path using as few control points as possible.

  • Adds methods PiecewiseLinearToBezier and PiecewiseLinearToBSpline to PathApproximator.
  • Adds TestScenePiecewiseLinearToBSpline to interactively test the algorithm on various curves with controllable parameters.
  • Adds BenchmarkPiecewiseLinearToBezier to benchmark the performance of the algorithm.

Utility

This addresses two points of concern mentioned in ppy/osu#25409:

It's very hard to use the freehand to create a shape you're trying to make.

The generated path more closely follows your input, so it should be easier to create the shape you are actually trying to create because you dont have to manually account for the inward bias.

The resultant points are too frequent to provide a good manual tweaking experience after creating

The path requires less control points to still follow your input, so there are less control points to require tweaking afterwards.

TODOs

A later PR will introduce interop with IncrementalBSplineBuilder. I plan to improve performance by re-using results from previous frames so fewer iterations are necessary per frame to optimize the control points (this also would decrease wobbling).

Also weight matrices can be cached in between frames for even more performance gains. I also want to use the total winding to increase precision in the approximation algorithm when necessary (on very long, complex paths).

Usability can be improved by locking previous control points which have been sufficiently optimised, so there is no wobble in the entire path while drawing the path.

Benchmarks

I added a nuget package to handle efficient large matrix multiplication, for this I added Tensor.NET. I chose this with performance in mind. I added a benchmark and compared performance against NumSharp, where Tensor.NET won by a large margin. Then I rewrote it to pure C# because Tensor.NET is not compatible and it got way more performant. Super large matrices might still be faster in Tensor.NET but that wont be the case most of the time.

Benchmark using NumSharp:
rider64_qFjXZbNaIq

Benchmark using Tensor.NET (about 10x faster and comparable to native numpy in python):
rider64_rFjk6UnHx5

Benchmark using pure C# and optimized allocations:
rider64_x4RZsMLJsy

Benchmark using System.Numerics.Tensors for SIMD:
rider64_zh4BIyM8cI

@Tom94 Please give feedback, what do you think about the results?

osu.Framework.Tests_I2au6DMQHJ.mp4

@bdach
Copy link
Collaborator

bdach commented Nov 20, 2023

There's this and there's #6055. I'm not even sure what to do with these right now.

@OliBomby At the point where this sort of stuff is happening, I'd appreciate at least a minimum effort of communication before opening this.

@OliBomby
Copy link
Contributor Author

OliBomby commented Nov 21, 2023

There's this and there's #6055. I'm not even sure what to do with these right now.

@OliBomby At the point where this sort of stuff is happening, I'd appreciate at least a minimum effort of communication before opening this.

I had mentioned in #6044 (comment) that I'd open this PR. It's mostly meant for @Tom94 to compare the utility this algorithm provides.

You may ignore reviewing code for now because I'll probably have to rewrite most of it anyways.

@Tom94
Copy link
Collaborator

Tom94 commented Nov 21, 2023

Hi, I find the path approximations from the video very impressive for the relatively small amount of code used in the reconstruction! Thanks a bunch for the follow-up.

I'd say this PR is orthogonal to the existing BSpline builder. We should probably keep the existing hard corner detection while being able to either drop-in replace the winding-based control point placement in between corners or use it as initialization for this algorithm. I feel like it'd be valuable to have this in framework in any case as something like a self-contained List<Vector2> optimizeBSpline(List<Vector2> initialCps, List<Vector2> targetPath) function that can be optionally enabled in IncrementalBSplineBuilder once it becomes production ready (perf and otherwise).

As you already point out, there are a few rough edges, but I think they can be ironed out. My main gripes would be:

  1. Control points are very far from the curve at high Tolerance values... but I think that regime is not so important anyway. A lower default value, combined with a (potentially tweakable) hard constraint on the maximum distance from the curve could solve this.
  2. The entire curve wobbles a lot when new points are added -- but you seem to already be on top of this in your TODO list. :)
  3. For the test scene, I recommend adding lines that visually connect the control points so it's easier to see which part of the BSpline they correspond to when there are self-intersections. This wasn't needed when control points would always fall on the input path, but now I find myself confused with some of the reconstructions.

@peppy
Copy link
Member

peppy commented Nov 21, 2023

Control points are very far from the curve at high Tolerance values

Not looking too deep into this, but just want to add that this is a blocker in terms of osu! side usability. Control points generated by all settings exposed to the user must look sane to the user so they can easily edit/tweak the curve after drawing it.

@OliBomby
Copy link
Contributor Author

Good news! I've rewritten the algorithm to not use Tensor.NET and be pure C# instead, and its actually 2x-10x faster and uses 100x times less heap allocations. I didn't expect a naive implementation without SIMD to still be this fast. Benchmark below.
rider64_x4RZsMLJsy
Though there is a lot more code now for all the matrix operations, so it might warrant being put in its own class.

Not looking too deep into this, but just want to add that this is a blocker in terms of osu! side usability. Control points generated by all settings exposed to the user must look sane to the user so they can easily edit/tweak the curve after drawing it.

The distance between control points and the curve depends on the locality and number of control points. I think if we restrict the order to be sufficiently low and add enough control points, the result should be close enough to the curve to look sane.

For the test scene, I recommend adding lines that visually connect the control points so it's easier to see which part of the BSpline they correspond to when there are self-intersections. This wasn't needed when control points would always fall on the input path, but now I find myself confused with some of the reconstructions.

I think this is a good addition, it lets us see more accurately if the control points are 'sane'.

@OliBomby OliBomby marked this pull request as ready for review November 24, 2023 15:22
@OliBomby
Copy link
Contributor Author

I've removed the changes to IncrementalBSplineBuilder to save that for another PR and not make this PR too huge. I've now marked this ready for review just for the PiecewiseLinearToBezier and PiecewiseLinearToBSpline methods.

@OliBomby OliBomby changed the title Optimize anchor placement for incremental B-spline builder Add method to convert piecewise linear curves to spline control points Nov 24, 2023
Copy link
Collaborator

@bdach bdach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is underdocumented and opaque

osu.Framework/Utils/PathApproximator.cs Outdated Show resolved Hide resolved
/// <returns>Matrix array of B-spline basis function values.</returns>
private static float[,] generateBSplineWeights(int numControlPoints, int numTestPoints, int degree)
{
// Calculate the basis function values using a modified De Boor's algorithm
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified how and why? Is there a citation for this?

(I'm not even attempting to check the correctness of this implementation until this review is responded to.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this comment is not accurate. I didn't use any specific algorithm as reference.
I started with the Cox-de Boor recursion formula and turned it into my own algorithm by applying some optimizations. First I used dynamic programming to remove the recursion, and then I realized some steps can be ignored because the knots on the edges are all zero width.
What I ended up with looked pretty similar to De Boor's algorithm so I added that comment to point to some resources which might explain what's going on here.
I should change the comment to say that this method is just using Cox-de Boor recursion formula, but highly optimized.

osu.Framework/Utils/PathApproximator.cs Show resolved Hide resolved
osu.Framework/Utils/PathApproximator.cs Show resolved Hide resolved
osu.Framework/Utils/PathApproximator.cs Show resolved Hide resolved
osu.Framework/Utils/PathApproximator.cs Outdated Show resolved Hide resolved
@peppy
Copy link
Member

peppy commented Dec 7, 2023

I'd personally like to get this in with minimal review overhead (along with #6066 / ppy/osu#25658).

Copy link
Collaborator

@bdach bdach left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still think this is opaque in places but since i'm getting told to move this on -


for (int i = 0; i < numTestPoints; i++)
{
prevOrder[i, (int)MathHelper.Clamp(x[i] * (numControlPoints - degree), 0, numControlPoints - degree - 1)] = 1;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why the upper range of the clamp is numControlPoints - degree - 1, but changing it to what I would consider more correct breaks things, so there must be a reason for this that I'm not understanding...

The reason why I was suspicious is that while the left-side clamp looks effectively dead (because x[i] should never be less than 0), the right-side clamp looks like it may cut off values on the right side. (the last one, specifically; x[^1] == 1, and as such, numControlPoints - degree will get clamped).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Cox-de Boor recursion formula, the first order is basically sorting this x[i] into a knot whose range includes x[i]. Because this is an open uniform knot vector, we got degree number of zero-width knots on the end-points and numControlPoints - degree number of equally spaced knots in the middle. The knots range is usually not inclusive on the right side but in this case i want the last non zero-width knot to be inclusive on the right side, so the non-zero width knots include the whole range [0, 1]. This is exactly what the clamp achieves here.

Comment on lines +688 to +702
// This code multiplies the previous order by equal length arrays of alphas and betas,
// then shifts the alpha array by one index, and adds the results, resulting in one extra length.
// nextOrder = (prevOrder * alphas).shiftRight() + (prevOrder * betas)
float prevAlpha = 0;

for (int j = 0; j < numControlPoints - degree + q - 1; j++)
{
float alpha = (x[i] - knots[degree - q + 1 + j]) / (knots[degree + 1 + j] - knots[degree - q + 1 + j]);
float alphaVal = alpha * prevOrder[i, j];
float betaVal = (1 - alpha) * prevOrder[i, j];
prevOrder[i, j] = prevAlpha + betaVal;
prevAlpha = alphaVal;
}

prevOrder[i, numControlPoints - degree + q - 1] = prevAlpha;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero citations, zero anything. I basically don't know where any of this comes from, but since I'm told to keep review minimal, I suppose I will just look away.

matmul(controlPoints, weights, points);

// Update labels to shift the distance distribution between points
if (step % 11 == 0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

random "every eleventh step" (why eleventh?)

Comment on lines +441 to +443
matLerp(grad, m, b1, m);
matProduct(grad, grad, grad);
matLerp(grad, v, b2, v);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that matLerp() mixes the first operand with weight 1 - t and the second with weight t makes this so confusing to read....... When cross-referencing against the adam paper I almost thought this had it backwards for a second.

@bdach bdach enabled auto-merge December 7, 2023 09:03
@bdach bdach merged commit 44f2f93 into ppy:master Dec 7, 2023
20 of 21 checks passed
@OliBomby OliBomby deleted the bezier-approximation-tensor branch December 13, 2023 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants