用 SymPy 办理 Manim 曲线绘制速率不均的题目

[复制链接]
发表于 2026-6-1 19:23:54 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
假如你想绘制一个参数曲线,好比:极坐标玫瑰线 r = cos(5θ)。
那么思绪很简单:算出曲线上的点,用 Create 一笔画出来。
但是,写完代码一渲染,题目来了:
花瓣尖端“唰”地一下就已往了,中心部分却慢吞吞的。整条曲线的绘制节奏忽快忽慢,看起来非常别扭。
题目的根源:我让参数 $ \theta $匀称递增,但曲线上点移动的现实间隔并不是匀称的。参数厘革快的地方,点就“飞”已往;参数厘革慢的地方,点就“爬”已往。要让画笔匀速移动,必须让参数按照弧长匀称分布——这就是弧长参数化。
手工做这件事险些不大概:求弧长得积分,反解参数得解方程,曲线轻微复杂一点就算不动了。
幸亏,我们有 SymPy。
1. 痛点场景还原:一个详细的例子

先看一段“有毛病”的代码,直观感受一下题目:
  1. from manim import *
  2. import numpy as np
  3. class BadRoseCurve(Scene):
  4.     def construct(self):
  5.         axes = Axes(x_range=[-3, 3], y_range=[-3, 3])
  6.         self.add(axes)
  7.         # 用累积密度法生成非均匀 theta:尖端稀疏(快),中部密集(慢)
  8.         n_points = 500
  9.         t = np.linspace(0, 2 * PI, n_points)
  10.         # 密度函数:在 cos(5t)=0 处(花瓣中部)密度高,在 |cos(5t)|=1 处(尖端)密度低
  11.         density = 1 + 3 * np.sin(5 * t) ** 2
  12.         theta = np.cumsum(density)
  13.         theta = theta / theta[-1] * PI  # 归一化到 [0, π](k为奇数时只需π即可画完)
  14.         r = np.cos(5 * theta)
  15.         x = r * 2 * np.cos(theta)
  16.         y = r * 2 * np.sin(theta)
  17.         points = [axes.c2p(x[i], y[i]) for i in range(n_points)]
  18.         curve = VMobject(color=PINK, stroke_width=3)
  19.         curve.set_points_as_corners(points)
  20.         # 用 Create 画曲线——速度明显不均匀!
  21.         self.play(Create(curve), run_time=5, rate_func=linear)
  22.         self.wait()
复制代码
运行这个动画,你会看到:花瓣尖端几帧就画完了,而花瓣中部却画得很慢。
rate_func=linear 控制的是动画进度的匀称,但曲线本身点的分布就是疏密不均的,以是视觉速率肯定忽快忽慢。
2. SymPy 办理方案:弧长参数化三步走

要让绘制速率匀称,核心思绪是:天生一组让相邻点之间现实间隔相称的参数值。详细分三步:

  • 用积分算出弧长函数 $ L(\theta) $——从出发点到参数 $ \theta $ 的弧长
  • 算总弧长,中分出一组目标弧长值 $ s_1, s_2, ... $
  • 对每个$ s_i \(,反解方程\) L(\theta)=s_i $,得到对应的 $ \theta_i $
这三步,SymPy 都能帮上忙。
2.1 用 SymPy 推导弧长函数
  1. import sympy as sp
  2. theta = sp.Symbol('theta', real=True)
  3. n = 5  # 五瓣玫瑰线
  4. # 极坐标方程(注意这里放大了2倍,与痛点代码中的 r*2 保持一致)
  5. r = 2 * sp.cos(n * theta)
  6. # 极坐标 → 直角坐标(符号推导,零误差)
  7. x = r * sp.cos(theta)  # x = r(θ)·cos(θ)
  8. y = r * sp.sin(theta)  # y = r(θ)·sin(θ)
  9. # 弧长微元:ds/dθ = sqrt((dx/dθ)² + (dy/dθ)²)
  10. dx_dtheta = sp.diff(x, theta)
  11. dy_dtheta = sp.diff(y, theta)
  12. # 弧长微元表达式(注意:这里不算积分,只保留被积函数)
  13. ds_dtheta = sp.sqrt(dx_dtheta**2 + dy_dtheta**2)
  14. print("弧长微元 ds/dθ =", sp.simplify(ds_dtheta))
  15. # 运行结果:
  16. '''
  17. 弧长微元 ds/dθ = 2*sqrt((2*sin(4*theta) +
  18.                      3*sin(6*theta))**2 +
  19.                      (2*cos(4*theta) -
  20.                      3*cos(6*theta))**2)
  21. '''
复制代码
SymPy 会输出一个椭圆积分情势的表达式——这是正常的,许多曲线的弧长都没有初等表达式。
不要紧,数值求解一样好用。
2.2 用 nsolve 反解等弧长参数点
  1. import numpy as np
  2. from scipy.integrate import quad
  3. from scipy.optimize import bisect  # 数值求根,稳定且快
  4. # 把弧长微元转成可数值计算的函数
  5. ds_func = sp.lambdify(theta, ds_dtheta, 'numpy')
  6. # 数值弧长函数:L(t) = ∫₀^t ds
  7. def arc_length(t):
  8.     """计算从 0 到 t 的弧长"""
  9.     result, _ = quad(ds_func, 0, t, limit=100)
  10.     return result
  11. # 计算总弧长
  12. total_length = arc_length(2 * np.pi)
  13. print(f"总弧长: {total_length:.4f}")
  14. # 等分弧长,用数值求根反解对应的 theta
  15. N = 500
  16. s_values = np.linspace(0, total_length, N)
  17. theta_vals = []
  18. # 辅助函数:f(t) = L(t) - s,我们要找 f(t)=0 的根
  19. def f(t, s):
  20.     return arc_length(t) - s
  21. for i, s in enumerate(s_values):
  22.     if i == 0:
  23.         theta_vals.append(0.0)  # s=0 对应 theta=0
  24.         continue
  25.     # 初始搜索区间:用上一个 theta 作为左边界
  26.     # 弧长是单调递增的,所以解一定在 [左边界, 右边界] 之间
  27.     left = theta_vals[-1]  # 上一个已解出的 theta
  28.     right = left + 0.5     # 向右扩展,足够覆盖下一个等分点
  29.     # 扩展右边界直到 f(right, s) > 0(确保根在区间内)
  30.     while f(right, s) < 0:
  31.         right += 0.5
  32.     # 二分法求根(稳定、快速)
  33.     sol = bisect(f, left, right, args=(s,), xtol=1e-8)
  34.     theta_vals.append(float(sol))
  35. theta_vals = np.array(theta_vals)
复制代码
代码要点:

  • sp.lambdify 把符号表达式编译成 NumPy 函数,求值快
  • sp.nsolve 数值解方程,给定一个好推测值能显着加速
  • 推测值用“弧长占比 × 总参数范围”做线性估计,富足靠近真实解
3. Manim 联动实战

把上面的盘算和 Manim 动画串起来,就是一份完备可运行的代码:
  1. from manim import *
  2. import numpy as np
  3. import sympy as sp
  4. from scipy.integrate import quad
  5. from scipy.optimize import bisect
  6. class UniformRoseCurve(Scene):
  7.     def construct(self):
  8.         # ===== SymPy + 数值积分计算等弧长参数点 =====
  9.         theta = sp.Symbol("theta", real=True)
  10.         n = 5
  11.         # 极坐标方程 r = 2*cos(5θ)
  12.         r = 2 * sp.cos(n * theta)
  13.         # 极坐标转直角坐标
  14.         x_expr = r * sp.cos(theta)
  15.         y_expr = r * sp.sin(theta)
  16.         # 弧长微元
  17.         dx = sp.diff(x_expr, theta)
  18.         dy = sp.diff(y_expr, theta)
  19.         ds_dtheta = sp.sqrt(dx**2 + dy**2)
  20.         # 数值弧长函数
  21.         ds_func = sp.lambdify(theta, ds_dtheta, "numpy")
  22.         def arc_length(t):
  23.             val, _ = quad(ds_func, 0, t, limit=100)
  24.             return val
  25.         # 总弧长(k为奇数时只需π即可画完)
  26.         total_len = arc_length(np.pi)
  27.         print(f"总弧长: {total_len:.4f}")
  28.         # 用二分法反解等弧长 theta(速度快)
  29.         N = 500
  30.         s_vals = np.linspace(0, total_len, N)
  31.         theta_vals = [0.0]
  32.         def f(t, s):
  33.             return arc_length(t) - s
  34.         for i in range(1, N):
  35.             s = s_vals[i]
  36.             left = theta_vals[-1]
  37.             right = left + 0.5
  38.             # 扩展右边界直到 f(right) > 0
  39.             while f(right, s) < 0:
  40.                 right += 0.5
  41.             sol = bisect(f, left, right, args=(s,), xtol=1e-8)
  42.             theta_vals.append(float(sol))
  43.         theta_vals = np.array(theta_vals)
  44.         # 计算直角坐标点
  45.         x_func = sp.lambdify(theta, x_expr, "numpy")
  46.         y_func = sp.lambdify(theta, y_expr, "numpy")
  47.         # ===== Manim 动画 =====
  48.         axes = Axes(x_range=[-3, 3], y_range=[-3, 3])
  49.         self.add(axes)
  50.         points = []
  51.         for t in theta_vals:
  52.             px = float(x_func(t))
  53.             py = float(y_func(t))
  54.             points.append(axes.c2p(px, py))
  55.         curve = VMobject(color=PINK, stroke_width=3)
  56.         curve.set_points_as_corners(points)
  57.         self.play(Create(curve), run_time=5, rate_func=linear)
  58.         self.wait()
复制代码
4. 效果展示分析

运行 UniformRoseCurve,对比之前的 BadRoseCurve,差异一览无余:

  • 绘制过程匀称流畅:粉色线条以恒定速率从出发点生长到尽头,花瓣尖端不再“闪现”,花瓣中部也不再“拖沓”。整条曲线在 10 秒内被安稳地画完,节奏非常惬意。
  • 同一曲线,两种体验:BadRoseCurve 由于刻意在花瓣中部堆了 3 倍密度,视觉上画笔会在那里显着减速;而弧长参数化版本抹平了全部速率颠簸,让 rate_func=linear 真正发挥作用。
  • 弧长参数化的本质:固然 theta_vals 的数值分布是不匀称的(花瓣尖端附近 θ 厘革慢,中部厘革快),但映射到平面后,相邻点之间的现实间隔完全相称。这就是“匀称”的真正寄义——空间上的匀称,而非参数上的匀称。
5. 本期小结


  • 题目本质:匀称参数 ≠ 匀称弧长,直接用匀称参数画曲线肯定速率不均。
  • 办理思绪:弧长参数化——先算弧长函数,再反解出等弧长分布的参数点。
  • SymPy 的脚色:

    • sp.diff → 求导得弧长微元
    • sp.integrate → 算弧长函数
    • sp.nsolve → 反解参数值
    • sp.lambdify → 表达式转数值函数,高效求值

  • Manim 中的用法:把等弧长点集喂给 VMobject.set_points_as_corners(),再用 Create + rate_func=linear,即可实现真正匀速的曲线绘制。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表