Manim物理模拟:别自己写欧拉了!

做物理模拟动画时,我遇到过一个坑。

当时想做一个弹簧振子的 Manim 动画:一个小球连接在弹簧上,在平衡位置附近往复振动。

我一开始的思路是------手动写欧拉法迭代

python 复制代码
# 当时写的"玩具级"数值积分代码
x = 1.0   # 初始位移
v = 0.0   # 初始速度
dt = 0.02 # 时间步长
k = 2.0   # 弹簧劲度系数
m = 1.0   # 质量

positions = []
for i in range(300):
    a = -k/m * x          # 加速度
    v += a * dt           # 更新速度
    x += v * dt           # 更新位置
    positions.append(x)

这段代码跑起来之后,小球确实动起来了。但看了几秒之后------小球越振幅度越大,能量明显不守恒。

欧拉法的数值误差在逐帧累积,像个隐形的外力在不断推着小球。

我当然可以换龙格-库塔法,但那意味着更复杂的代码、更长的调试时间。我只是想做一个弹簧振子动画,为什么要从数值分析开始写起?

直到我开始用 SymPydsolve,才发现原来我根本不需要自己写数值积分。

1. 痛点场景还原

Manim 中做物理模拟动画,常见的"手工数值积分"方案有三大痛点:

痛点 具体表现
数值误差累积 欧拉法能量不守恒,弹簧振子越振越离谱
步长调参折磨 dt 设太小渲染慢,设太大精度炸,反复试错
无法利用解析解 明明简谐振子有精确的 cos(ωt) 解析表达式,却硬要用数值去逼近

更关键的是,Manim 的动画是基于 ValueTracker 的时间驱动的

我们真正需要的是一个函数 x(t),能根据当前时刻 t 精确返回小球的位置。

这和 SymPy 的解析解简直是天作之合。

python 复制代码
# Manim 动画的本质:需要一个时间→位置的映射函数
def get_position(t):
    # 如果这里能直接用解析解,该多好!
    return ???  

SymPy 的 dsolve 可以帮我们求出这个解析的 x(t),然后通过 lambdify 把它转成 NumPy 函数,直接注入 ValueTracker 的更新逻辑中。全程无误差累积,因为每一帧的位置都是独立计算的。

2. SymPy 解决方案

2.1 dsolve:符号求解微分方程

SymPy 的 dsolve 可以求解常微分方程(ODE)的解析解。以弹簧振子(简谐振动)为例:

运动方程: m d 2 x d t 2 + k x = 0 m\frac{d^2x}{dt^2} + kx = 0 mdt2d2x+kx=0,即 d 2 x d t 2 + ω 0 2 x = 0 \frac{d^2x}{dt^2} + \omega_0^2 x = 0 dt2d2x+ω02x=0,其中 ω 0 = k m \omega_0 = \sqrt{\frac{k}{m}} ω0=mk 是系统的固有角频率

python 复制代码
import sympy as sp

# 定义符号
t = sp.Symbol('t', real=True)           # 时间
x = sp.Function('x')(t)                 # 位移函数 x(t)
m, k = sp.symbols('m k', positive=True)  # 质量和劲度系数
omega = sp.sqrt(k/m)                     # 角频率

# 定义微分方程:m * x'' + k * x = 0
ode = sp.Eq(m * sp.diff(x, t, 2) + k * x, 0)

# 代入初始条件求解
# 设初始位移 x(0) = 1,初始速度 x'(0) = 0
initial_conditions = {
    x.subs(t, 0): 1,                # x(0) = 1
    sp.diff(x, t).subs(t, 0): 0     # x'(0) = 0
}

# dsolve 求解!
solution = sp.dsolve(ode, ics=initial_conditions)
print(solution)  # 输出:x(t) = cos(√(k/m)·t)

SymPy 直接给出了解析解: x ( t ) = cos ⁡ ( ω t ) x(t) =\cos(\omega t) x(t)=cos(ωt),这就是我们熟知的简谐振动公式。

2.2 lambdify:符号表达式 → 高性能数值函数

有了符号表达式 x ( t ) = cos ⁡ ( ω t ) x(t) =\cos(\omega t) x(t)=cos(ωt),下一步是把它变成 Manim 可以每秒调用几十次的快速函数。lambdify 就是干这个的:

python 复制代码
import numpy as np

# 提取解表达式右侧的 x(t)
x_expr = solution.rhs  # cos(sqrt(k/m)*t)

# 代入具体参数值:k=4, m=1 → ω=2
x_expr_sub = x_expr.subs({k: 4, m: 1})  # cos(2*t)

# lambdify:将 SymPy 表达式编译为 NumPy 函数
# 参数是 t,返回 NumPy 数组
x_func = sp.lambdify(t, x_expr_sub, modules='numpy')

# 现在 x_func 可以像普通 NumPy 函数一样使用
print(x_func(0))      # 1.0
print(x_func(np.pi/4)) # cos(π/2) ≈ 0.0
print(x_func(np.pi/2)) # cos(π) = -1.0

lambdify 的三个关键优势

  1. 速度快 :底层转成 NumPy 运算,支持向量化,比 SymPy 的 .subs() 快几个数量级
  2. 无缝衔接:返回的就是标准 Python 可调用对象,直接用在任何需要函数的地方
  3. 支持数组输入:可以一次计算整段时间的所有位置,便于做轨迹预览

2.3 扩展到阻尼振动和单摆

对于更复杂的微分方程,dsolve + lambdify 的组合依然好用:

欠阻尼振动 d 2 x d t 2 + 2 β d x d t + ω 0 2 x = 0 \frac{d^2x}{dt^2} + 2\beta\frac{dx}{dt} + \omega_0^2 x = 0 dt2d2x+2βdtdx+ω02x=0):

python 复制代码
beta, omega0 = sp.symbols('beta omega0', positive=True)

# 欠阻尼方程
ode_damped = sp.Eq(sp.diff(x, t, 2) + 2*beta*sp.diff(x, t) + omega0**2*x, 0)
sol_damped = sp.dsolve(ode_damped, ics={
    x.subs(t, 0): 1,
    sp.diff(x, t).subs(t, 0): 0
})

# 代入参数后 lambdify
x_damped_expr = sol_damped.rhs.subs({beta: 0.3, omega0: 2})
x_damped_func = sp.lambdify(t, x_damped_expr, 'numpy')

小角度单摆 θ ′ ′ + g L sin ⁡ ( θ ) = 0 \theta'' + \frac{g}{L}\sin(\theta) = 0 θ′′+Lgsin(θ)=0,小角度近似 sin ⁡ ( θ ) ≈ θ \sin(\theta) \approx \theta sin(θ)≈θ):

python 复制代码
g_val, L_val = 9.8, 2.0
omega = sp.sqrt(g_val/L_val)  # 角频率 ω = √(g/L)

# ========== 直接写出通解形式 ==========
# 小角度近似后的方程:θ'' + ω²θ = 0
# 通解:θ(t) = A·cos(ωt) + B·sin(ωt)

A, B = sp.symbols('A B')  # 待定系数
theta_expr = A * sp.cos(omega * t) + B * sp.sin(omega * t)

# ========== 手动代入初始条件 ==========
theta_0 = sp.pi / 6          # 初始角度 30°
dtheta_expr = sp.diff(theta_expr, t)  # 角速度表达式

# 条件1:θ(0) = π/6  →  A = π/6
eq1 = sp.Eq(theta_expr.subs(t, 0), theta_0)

# 条件2:θ'(0) = 0  →  ω·B = 0  →  B = 0
eq2 = sp.Eq(dtheta_expr.subs(t, 0), 0)

# 求解待定系数
solution = sp.solve([eq1, eq2], [A, B])
print(f"A = {solution[A]}, B = {solution[B]}")

# 代入得到精确解
theta_final = theta_expr.subs(solution)
print(f"θ(t) = {theta_final}")
# 输出:θ(t) = pi*cos(√(g/L)*t)/6

# ========== lambdify 转为 NumPy 函数 ==========
theta_func = sp.lambdify(t, theta_final, 'numpy')

3. Manim 联动实战

下面是一个弹簧振子动画的核心代码示例。

核心思路是:用 SymPy 解出 x(t) ,用 lambdify 转成快速函数,再通过 ValueTracker 的追踪器模式驱动小球运动

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

class SpringOscillator(Scene):
    def construct(self):
        # ========== SymPy 符号求解微分方程 ==========
        t_sym = sp.Symbol("t", real=True)
        m_sym, k_sym = sp.symbols("m k", positive=True)

        # 弹簧振子方程:m·x'' + k·x = 0  →  通解 x(t) = A·cos(ωt) + B·sin(ωt)
        A, B = sp.symbols("A B")
        omega = sp.sqrt(k_sym / m_sym)
        x_expr = A * sp.cos(omega * t_sym) + B * sp.sin(omega * t_sym)

        # 手动代入初始条件:x(0)=2, x'(0)=0
        eq1 = sp.Eq(x_expr.subs(t_sym, 0), 2)              # A = 2
        eq2 = sp.Eq(sp.diff(x_expr, t_sym).subs(t_sym, 0), 0)  # ω·B = 0
        sol_const = sp.solve([eq1, eq2], [A, B])

        # 代入参数:m=1, k=4 → ω=2 → x(t) = 2cos(2t)
        x_final = x_expr.subs(sol_const).subs({m_sym: 1, k_sym: 4})
        x_func = sp.lambdify(t_sym, x_final, "numpy")  # 编译为 NumPy 函数

        # ========== Manim 场景搭建 ==========
        number_line = NumberLine(x_range=[-3, 3, 1], length=8).shift(DOWN * 1.5)
        self.play(Create(number_line))

        fixed_point = number_line.number_to_point(-3) + UP * 0.8
        ball = Circle(radius=0.2, color=RED, fill_opacity=0.8)
        ball.move_to(number_line.number_to_point(x_func(0)) + UP * 0.8)

        self.play(DrawBorderThenFill(ball))

        # ========== ValueTracker 驱动动画(核心机制)==========
        time_tracker = ValueTracker(0)  # 虚拟时钟

        # 更新器:每帧用 SymPy 解析解计算精确位置
        ball.add_updater(lambda m: m.set_x(
            number_line.number_to_point(
                x_func(time_tracker.get_value())  # x(t) 精确值!
            )[0]
        ))

        # 播放:虚拟时间从 0 流逝到 5 秒
        self.play(
            time_tracker.animate.set_value(5),
            run_time=5,
            rate_func=linear,
        )
        self.wait(1)

4. 效果展示说明

运行上面的脚本,你会看到这样的动画流程:

观察重点

  • 小球的运动完全符合余弦规律:在两端停顿、中间最快,完美再现简谐振动
  • 弹簧长度随小球位置实时变化,视觉上非常自然
  • 整个过程没有任何数值积分代码 ------你写的只是方程和初始条件,剩下的交给 SymPy

换成阻尼振动有多容易?

只需要改两行代码:

python 复制代码
# 阻尼振动方程
ode_damped = sp.Eq(
    sp.diff(x_sym, t_sym, 2) + 0.6*sp.diff(x_sym, t_sym) + 4*x_sym, 0
)
# dsolve 会自动处理,lambdify 之后的用法完全一样

小球的振幅会逐渐减小,直到停在平衡位置。而这一切,你不需要修改任何动画逻辑 ------因为 x_func 这个黑盒函数的内部实现已经被 SymPy 替换了。

5. 本期小结

核心公式dsolve → lambdify → ValueTracker

步骤 工具 作用
1. 定义微分方程 sp.Eq(sp.diff(...)) 用符号表达物理规律
2. 求解析解 sp.dsolve(ode, ics=...) 得出 x(t) 的封闭表达式
3. 编译为快函数 sp.lambdify(t, expr, 'numpy') 符号表达式 → 每秒可调用千次的 NumPy 函数
4. 驱动 Manim 动画 ValueTracker + add_updater 每帧把虚拟时间 t 喂给函数,更新物体位置
相关推荐
Angel Q.8 小时前
特征提取 | DINO 到 DINOv3
图像处理·python
不如语冰9 小时前
AI大模型入门-pytorch-张量2 tensor创建
python
NPE~9 小时前
[AI]Agent开发——ADK框架使用
人工智能·python·ai·教程·adk·agent开发
矮个史蒂芬9 小时前
统计verilog .v文件中output 的bit数
python
gwf21610 小时前
磨损均衡算法(Wear Leveling)——SSD如何让每块闪存“公平退休“?
运维·数据库·人工智能·python·嵌入式硬件·算法·智能硬件
爱吃提升10 小时前
python 分布式爬虫、爬虫合规与综合实战项目
分布式·爬虫·python
审小匠OpenCPAi10 小时前
银行流水核查怎么自动化?单边匹配、双向勾稽与图聚类异常检测的工程对比
java·前端·人工智能·python·审计
元Y亨H11 小时前
Pandas 解析 Excel 导致的内存溢出(MemoryError)
python·excel
金銀銅鐵12 小时前
[Python] 用 turtle 来绘制瑞典国旗
python
Albert Edison12 小时前
【GUI 自动化测试】Pywinauto 常见操作
自动化测试·python·pywinauto