用 SymPy 解决 Manim 曲线绘制速度不均的问题
Manim曲线绘制时均匀参数导致弧长分布不均,引起视觉速度忽快忽慢。通过SymPy符号推导弧长微元,数值积分计算弧长函数,再结合二分法反解等弧长参数点,生成空间均匀的采样点。将等弧长点集送入Manim的VMobject,配合线性速率函数,实现匀速绘制。该方法有效解决了玫瑰线等参数曲线绘制速度不均的问题。
你可能遇到过这个问题:你想绘制一条参数曲线,比方说极坐标玫瑰线 r = cos(5θ)。

逻辑很简单——算出曲线上的点,然后用 Create 一笔画出来就行。
可代码一渲染,发现问题了:
花瓣尖端“唰”地一闪而过,中部却又慢吞吞地像在爬。整条曲线节奏忽快忽慢,看着十分别扭。
问题出在哪?因为参数 虽然均匀递增,但它在曲线上映射出的实际距离却不均匀。参数变化快的区域,点就“飞”过去;变化慢的区域,点就“爬”过去。要让画笔匀速移动,参数必须按照弧长均匀分布——这就是弧长参数化的核心诉求。
手动做这件事,基本是炼狱难度:求弧长得积分,反解参数又得解方程,曲线稍微复杂一点就算不动了。
好在有 SymPy 这个利器。
1. 痛点场景还原:一个具体的例子
先看一段“有毛病”的代码,直观感受一下问题:
from manim import *
import numpy as npclass BadRoseCurve(Scene):
def construct(self):
axes = Axes(x_range=[-3, 3], y_range=[-3, 3])
self.add(axes) # 用累积密度法生成非均匀 theta:尖端稀疏(快),中部密集(慢)
n_points = 500
t = np.linspace(0, 2 * PI, n_points)
# 密度函数:在 cos(5t)=0 处(花瓣中部)密度高,在 |cos(5t)|=1 处(尖端)密度低
density = 1 + 3 * np.sin(5 * t) ** 2
theta = np.cumsum(density)
theta = theta / theta[-1] * PI # 归一化到 [0, π](k为奇数时只需π即可画完)
r = np.cos(5 * theta)
x = r * 2 * np.cos(theta)
y = r * 2 * np.sin(theta) points = [axes.c2p(x[i], y[i]) for i in range(n_points)]
curve = VMobject(color=PINK, stroke_width=3)
curve.set_points_as_corners(points) # 用 Create 画曲线——速度明显不均匀!
self.play(Create(curve), run_time=5, rate_func=linear)
self.wait()
运行这个动画,花瓣尖端几帧就画完了,中部却慢悠悠地推进。即便 rate_func=linear 保证动画进度本身是线性的,但点的分布已经疏密不均,视觉速度自然忽快忽慢。
2. SymPy 解决方案:弧长参数化三步走
要让绘制速度均匀,核心思路是:生成一组让相邻点之间实际距离相等的参数值。具体分三步:
- 用积分算出弧长函数 ——从起点到参数 走过的弧长
- 计算总弧长,等分出一组目标弧长值
- 对每个 ,反解方程 ,得到对应的
这三步,SymPy 都能帮上忙。
2.1 用 SymPy 推导弧长函数
import sympy as sptheta = sp.Symbol('theta', real=True)
n = 5 # 五瓣玫瑰线# 极坐标方程(注意这里放大了2倍,与痛点代码中的 r*2 保持一致)
r = 2 * sp.cos(n * theta)# 极坐标 → 直角坐标(符号推导,零误差)
x = r * sp.cos(theta) # x = r(θ)·cos(θ)
y = r * sp.sin(theta) # y = r(θ)·sin(θ)# 弧长微元:ds/dθ = sqrt((dx/dθ)² + (dy/dθ)²)
dx_dtheta = sp.diff(x, theta)
dy_dtheta = sp.diff(y, theta)# 弧长微元表达式(注意:这里不算积分,只保留被积函数)
ds_dtheta = sp.sqrt(dx_dtheta**2 + dy_dtheta**2)
print("弧长微元 ds/dθ =", sp.simplify(ds_dtheta))# 运行结果:
'''
弧长微元 ds/dθ = 2*sqrt((2*sin(4*theta) +
3*sin(6*theta))**2 +
(2*cos(4*theta) -
3*cos(6*theta))**2)
'''
输出是椭圆积分形式——很正常,很多曲线的弧长都没有初等表达式。但没关系,数值求解一样高效。
2.2 用 nsolve 反解等弧长参数点
import numpy as np
from scipy.integrate import quad
from scipy.optimize import bisect # 数值求根,稳定且快# 把弧长微元转成可数值计算的函数
ds_func = sp.lambdify(theta, ds_dtheta, 'numpy')# 数值弧长函数:L(t) = ∫₀^t ds
def arc_length(t):
"""计算从 0 到 t 的弧长"""
result, _ = quad(ds_func, 0, t, limit=100)
return result# 计算总弧长
total_length = arc_length(2 * np.pi)
print(f"总弧长: {total_length:.4f}")# 等分弧长,用数值求根反解对应的 theta
N = 500
s_values = np.linspace(0, total_length, N)
theta_vals = []# 辅助函数:f(t) = L(t) - s,我们要找 f(t)=0 的根
def f(t, s):
return arc_length(t) - sfor i, s in enumerate(s_values):
if i == 0:
theta_vals.append(0.0) # s=0 对应 theta=0
continue # 初始搜索区间:用上一个 theta 作为左边界
# 弧长是单调递增的,所以解一定在 [左边界, 右边界] 之间
left = theta_vals[-1] # 上一个已解出的 theta
right = left + 0.5 # 向右扩展,足够覆盖下一个等分点 # 扩展右边界直到 f(right, s) > 0(确保根在区间内)
while f(right, s) < 0:
right += 0.5 # 二分法求根(稳定、快速)
sol = bisect(f, left, right, args=(s,), xtol=1e-8)
theta_vals.append(float(sol))theta_vals = np.array(theta_vals)
代码要点:
sp.lambdify把符号表达式编译成 NumPy 函数,求值效率拉满bisect二分法求根,比纯牛顿法更稳定,适合单调函数场景- 搜索区间利用弧长单调递增特性,用上一个解作为左边界,大幅缩小搜索范围
3. Manim 联动实战
把上面的计算和 Manim 动画串起来,就是一份完整可运行的代码:
from manim import *
import numpy as np
import sympy as sp
from scipy.integrate import quad
from scipy.optimize import bisectclass UniformRoseCurve(Scene):
def construct(self):
# ===== SymPy + 数值积分计算等弧长参数点 =====
theta = sp.Symbol("theta", real=True)
n = 5 # 极坐标方程 r = 2*cos(5θ)
r = 2 * sp.cos(n * theta) # 极坐标转直角坐标
x_expr = r * sp.cos(theta)
y_expr = r * sp.sin(theta) # 弧长微元
dx = sp.diff(x_expr, theta)
dy = sp.diff(y_expr, theta)
ds_dtheta = sp.sqrt(dx**2 + dy**2) # 数值弧长函数
ds_func = sp.lambdify(theta, ds_dtheta, "numpy") def arc_length(t):
val, _ = quad(ds_func, 0, t, limit=100)
return val # 总弧长(k为奇数时只需π即可画完)
total_len = arc_length(np.pi)
print(f"总弧长: {total_len:.4f}") # 用二分法反解等弧长 theta(速度快)
N = 500
s_vals = np.linspace(0, total_len, N)
theta_vals = [0.0] def f(t, s):
return arc_length(t) - s for i in range(1, N):
s = s_vals[i]
left = theta_vals[-1]
right = left + 0.5
# 扩展右边界直到 f(right) > 0
while f(right, s) < 0:
right += 0.5
sol = bisect(f, left, right, args=(s,), xtol=1e-8)
theta_vals.append(float(sol)) theta_vals = np.array(theta_vals) # 计算直角坐标点
x_func = sp.lambdify(theta, x_expr, "numpy")
y_func = sp.lambdify(theta, y_expr, "numpy") # ===== Manim 动画 =====
axes = Axes(x_range=[-3, 3], y_range=[-3, 3])
self.add(axes) points = []
for t in theta_vals:
px = float(x_func(t))
py = float(y_func(t))
points.append(axes.c2p(px, py)) curve = VMobject(color=PINK, stroke_width=3)
curve.set_points_as_corners(points) self.play(Create(curve), run_time=5, rate_func=linear)
self.wait()
整个流程非常清晰:符号推导弧长微元,数值积分算弧长,二分法反解等距点,最后送入 Manim 绘制。
4. 效果展示说明
运行 UniformRoseCurve,对比之前的 BadRoseCurve,差异一目了然:
- 绘制过程均匀流畅:粉色线条以恒定速度从起点生长到终点,花瓣尖端不再“闪现”,中部也不再“拖沓”。整条曲线在 5 秒内被平稳地画完,节奏非常舒服。
- 同一曲线,两种体验:
BadRoseCurve刻意在花瓣中部堆了 3 倍密度,视觉上画笔会在那里明显减速;而弧长参数化版本抹平了所有速度波动,让rate_func=linear真正发挥作用。 - 弧长参数化的本质:虽然
theta_vals的数值分布是不均匀的(花瓣尖端附近 θ 变化慢,中部变化快),但映射到平面后,相邻点之间的实际距离完全相等——这才是均匀的真正含义:空间上的均匀,而非参数上的均匀。
5. 本期小结
- 问题本质:均匀参数 ≠ 均匀弧长,直接用均匀参数画曲线必然速度不均。
- 解决思路:弧长参数化——先算弧长函数,再反解出等弧长分布的参数点。
- SymPy 的角色:
sp.diff→ 求导得弧长微元sp.integrate→ 算弧长函数sp.nsolve/scipy.optimize.bisect→ 反解参数值sp.lambdify→ 表达式转数值函数,高效求值
- Manim 中的用法:把等弧长点集喂给
VMobject.set_points_as_corners(),再用Create+rate_func=linear,即可实现真正匀速的曲线绘制。


































