用 SymPy 解决 Manim 曲线绘制速度不均的问题

如果你想绘制一个参数曲线,比如:极坐标玫瑰线 r = cos(5θ)

那么思路很简单:算出曲线上的点,用 Create 一笔画出来。

但是,写完代码一渲染,问题来了:

花瓣尖端"唰"地一下就过去了,中间部分却慢吞吞的。整条曲线的绘制节奏忽快忽慢,看起来十分别扭。

问题的根源 :我让参数 θ\theta θ均匀递增,但曲线上点移动的实际距离并不是均匀的。参数变化快的地方,点就"飞"过去;参数变化慢的地方,点就"爬"过去。要让画笔匀速移动,必须让参数按照弧长 均匀分布------这就是弧长参数化

手工做这件事几乎不可能:求弧长得积分,反解参数得解方程,曲线稍微复杂一点就算不动了。

好在,我们有 SymPy

1. 痛点场景还原:一个具体的例子

先看一段"有毛病"的代码,直观感受一下问题:

python 复制代码
from manim import *
import numpy as np

class 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 解决方案:弧长参数化三步走

要让绘制速度均匀,核心思路是:生成一组让相邻点之间实际距离相等的参数值。具体分三步:

  1. 用积分算出弧长函数 L(θ)L(\theta) L(θ)------从起点到参数 θ\theta θ 的弧长
  2. 算总弧长,等分出一组目标弧长值 s1,s2,... s_1, s_2, ... s1,s2,...
  3. 对每个 si s_i si,反解方程 L(θ)=siL(\theta)=s_i L(θ)=si,得到对应的 θi \theta_i θi

这三步,SymPy 都能帮上忙。

2.1 用 SymPy 推导弧长函数

python 复制代码
import sympy as sp

theta = 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)
'''

SymPy 会输出一个椭圆积分形式的表达式------这是正常的,很多曲线的弧长都没有初等表达式。

没关系,数值求解一样好用。

2.2 用 nsolve 反解等弧长参数点

python 复制代码
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) - s

for 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 函数,求值快
  • sp.nsolve 数值解方程,给定一个好猜测值能显著加速
  • 猜测值用"弧长占比 × 总参数范围"做线性估计,足够接近真实解

3. Manim 联动实战

把上面的计算和 Manim 动画串起来,就是一份完整可运行的代码:

python 复制代码
from manim import *
import numpy as np
import sympy as sp
from scipy.integrate import quad
from scipy.optimize import bisect

class 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()

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,即可实现真正匀速的曲线绘制。
相关推荐
笨鸟先飞,勤能补拙20 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能21 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂1 天前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study1 天前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境1 天前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境1 天前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1231 天前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102161 天前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb001 天前
一个项目带你入门AI应用开发01
python
0566461 天前
Python康复训练——常用标准库
开发语言·python·学习