Rewrite `manim.utils.bezier.get_quadratic_approximation_of_cubic()` to produce curves which can be animated smoothly
Overview: What does this pull request change?
- What the title says
- Added a new test for this function
Motivation and Explanation: Why and how do your changes improve the library?
The current implementation splits the cubic Bézier at its inflection point (if it exists, otherwise simply t = 0.5). This sounds quite right: the curvature changes at the inflection point, something which can't be captured by a single quadratic Bézier, so we split the curve into two at that point in order to approximate each curvature with different curves.
However:
https://github.com/ManimCommunity/manim/assets/49853152/6fc5b352-92a7-4787-9bf6-57f002547114
As you can see, the speed of the point (1st derivative of the Béziers) is discontinuous when transitioning from one quadratic to the other.
This is problematic for 2 reasons:
- An animation like
Create,WriteorDrawBorderThenFillwon't draw the resultantVMobjectas smoothly as it could (the drawing speed might suddenly change)... even if it the original cubic spline was actually smooth. - OpenGL uses quadratic Béziers whereas Cairo uses cubic Béziers. When
OpenGLVMobject.make_smooth()is called, it interpolates the curve anchors with a smooth cubic spline, and then proceeds to approximate it with quadratic Béziers by callingget_quadratic...(). So,OpenGLVMobject.make_smooth()promises to transform the Mobject into a smooth curve, butget_quadratic...()breaks that promise.
Therefore I propose an alternate implementation: instead of forcing a split at a specific point on the curve, I explicitly require that the curves and their 1st derivatives are continuous. (See the docstring of get_quadratic...() for more details on the mathematical process)
With this implementation, the result is as follows:
https://github.com/ManimCommunity/manim/assets/49853152/074a82b5-a20b-4440-a32a-0434a58a01b6
Another example with more exotic curves (the spline is, however, not smooth, so the speeds at the green points aren't continuous:
- Before:
https://github.com/ManimCommunity/manim/assets/49853152/2ed4f09a-6731-402d-b1f3-08d35ffca6e6
- After:
https://github.com/ManimCommunity/manim/assets/49853152/d2b18322-f2d4-4118-b0c8-ccabab852f6e
Notice that the resultant curve might be slightly more off than the original approximation. The original seems more proper for static images where it's enough that the tangents are continuous (the speed directions are the same, rather than the speeds themselves). However, Manim is mainly an animation library, so it's necessary that the speeds are also continuous in this case.
Links to added or changed documentation pages
https://manimce--3829.org.readthedocs.build/en/3829/reference/manim.utils.bezier.html#manim.utils.bezier.get_quadratic_approximation_of_cubic
Further Information and Comments
The code I used:
from manim import *
from manim.utils.bezier import get_quadratic_approximation_of_cubic
from manim.typing import CubicBezierPoints
class BezierScene(Scene):
def construct(self):
base = VMobject().set_points_as_corners([
[-5, 2, 0],
[-2, 2, 0],
[-3, 0, 0],
[5, -3, 0],
]).make_smooth()
# Change the contents to whatever CubicBezierPoints you want
cubic_bezier_points: list[CubicBezierPoints] = [
base.points[i:i+4]
for i in range(0, len(base.points), 4)
# COMMENT THE TWO LINES ABOVE AND UNCOMMENT THE ARRAYS BELOW FOR THE 2ND EXAMPLE
# np.array([
# [-5, -1, 0],
# [-5, 2, 0],
# [-3, 2, 0],
# [-2, -2, 0],
# ]),
# np.array([
# [-2, -2, 0],
# [2, -2, 0],
# [-2, 4, 0],
# [2, 2, 0],
# ]),
# np.array([
# [2, 2, 0],
# [6, -2, 0],
# [0, -2, 0],
# [5, 3, 0],
# ]),
]
cubic_beziers = [bezier(p) for p in cubic_bezier_points]
cubic_derivative_points = [3 * (p[1:] - p[:-1]) for p in cubic_bezier_points]
cubic_derivatives = [bezier(d) for d in cubic_derivative_points]
quadratic_bezier_points = []
# Build VMobjects curve by curve, adding separating dots
cubic_vmob = VMobject(stroke_color=RED)
quadratic_vmob = VMobject(stroke_color=YELLOW)
for c in cubic_bezier_points:
(
cubic_vmob
.start_new_path(c[0])
.add_cubic_bezier_curve_to(*c[1:])
.add(Dot(c[0], color=GREEN))
)
Q = get_quadratic_approximation_of_cubic(*c)
q0, q1 = Q[:3], Q[3:]
quadratic_bezier_points.append(q0)
quadratic_bezier_points.append(q1)
(
quadratic_vmob
.start_new_path(q0[0])
.add_quadratic_bezier_curve_to(*q0[1:])
.add_quadratic_bezier_curve_to(*q1[1:])
.add(Dot(q0[0], color=GREEN), Dot(q1[0], color=BLUE))
)
cubic_vmob.add(Dot(cubic_bezier_points[-1][-1], color=GREEN))
quadratic_vmob.add(Dot(cubic_bezier_points[-1][-1], color=GREEN))
quadratic_beziers = [bezier(p) for p in quadratic_bezier_points]
quadratic_derivative_points = [2 * (p[1:] - p[:-1]) for p in quadratic_bezier_points]
quadratic_derivatives = [bezier(d) for d in quadratic_derivative_points]
t = ValueTracker(0) # between 0 and 3
def dot_cubic_updater(dot: Dot):
val = t.get_value()
i, alpha = integer_interpolate(0, 3, val) if val < 3 else (2, 1.0)
B = cubic_beziers[i]
dot.move_to(B(alpha))
def arrow_cubic_updater(arrow: Arrow) -> Arrow:
val = t.get_value()
i, alpha = integer_interpolate(0, 3, val) if val < 3 else (2, 1.0)
B = cubic_beziers[i]
dB = cubic_derivatives[i]
arrow.put_start_and_end_on(B(alpha), B(alpha) + dB(alpha)/2)
dot = Dot(color=WHITE, radius=0.15).add_updater(dot_cubic_updater)
arrow = Arrow(color=WHITE, buff=0.0).add_updater(arrow_cubic_updater)
self.wait(0.5)
self.play(FadeIn(cubic_vmob))
self.play(FadeIn(dot, arrow))
self.play(t.animate.set_value(1), run_time=6, rate_func=linear)
self.play(FadeOut(dot, arrow))
dot.clear_updaters()
arrow.clear_updaters()
def dot_quadratic_updater(dot: Dot):
val = t.get_value()
i, alpha = integer_interpolate(0, 6, val) if val < 6 else (5, 1.0)
B = quadratic_beziers[i]
dot.move_to(B(alpha))
def arrow_quadratic_updater(arrow: Arrow) -> Arrow:
val = t.get_value()
i, alpha = integer_interpolate(0, 6, val) if val < 6 else (5, 1.0)
B = quadratic_beziers[i]
dB = quadratic_derivatives[i]
arrow.put_start_and_end_on(B(alpha), B(alpha) + dB(alpha)/2)
dot.add_updater(dot_quadratic_updater)
arrow.add_updater(arrow_quadratic_updater)
t.set_value(0)
self.play(FadeIn(quadratic_vmob))
self.play(FadeIn(dot, arrow))
self.play(t.animate.set_value(1), run_time=12, rate_func=linear)
self.play(FadeOut(dot, arrow))
Reviewer Checklist
- [ ] The PR title is descriptive enough for the changelog, and the PR is labeled correctly
- [ ] If applicable: newly added non-private functions and classes have a docstring including a short summary and a PARAMETERS section
- [ ] If applicable: newly added functions and classes are tested