1. 从一条视频说起:为什么我需要manim
先讲个我的经历。早几年给学生讲傅里叶变换,公式推了三页黑板,台下眼神已经开始涣散。我试着用PPT画了几张静态示意图,效果依旧一般。后来无意中看到3Blue1Brown的数学视频,那种动态的、会“生长”的曲线,把一个抽象定理拆成肉眼可见的变化过程,我当时第一反应是:这玩意儿是怎么做出来的?查了一圈,发现它背后是一个叫做manim的Python动画引擎,全称是Mathematical Animation Engine,直译过来就是“数学动画引擎”。
manim并不是一个给你准备好素材、你只管套模板的傻瓜软件。恰恰相反,它更像一套用代码“雕刻”动画的工具包——你在Python里描述每一帧的画面、每一个对象的位置、每一段变换的逻辑,manim负责把这些描述渲染成一段视频。这么说吧,别人用剪辑软件拼素材,你用manim生产素材;别人在PS里画图,你用manim把图画“演”出来。它的核心价值在于:让数学对象(函数图像、几何图形、公式、坐标系)成为动画中的“演员”,让它们按照你设定的剧本动起来。
这篇文章是manim系列的第一篇,聚焦基本概念。我会尽量用一个完整的小例子把manim最核心的“世界观”讲透——什么是Mobject、什么是动画(Animation)、什么是场景(Scene)、代码文件是怎么组织运行的,以及你在第一次上手时最容易踩的坑。无论你是数学老师、编程爱好者,还是单纯觉得数学视频很酷的观众,这篇文章都能帮你从零开始搭起manim的骨架。
先说清楚一个前提:我用的manim版本是社区版,也就是manimgl还是manimce?这里必须分清。早年3Blue1Brown使用的版本后来被社区分支重新维护,命名为Manim Community Edition,简称manim。安装命令是pip install manim,这个版本迭代快、文档全、案例多,所有网上常见的manim教程也基本默认这个版本。因此下文凡是提到“manim”,统统指社区版,如果你在别的教程里看到manimlib或者manimgl,那是另一个分支,别混用。
2. 核心概念一:Mobject——动画里的“演员”
2.1 什么是Mobject
Mobject是manim最重要的基础类,全称是Mathematical Object,数学对象。你可以把它理解成动画世界里所有可视元素的父类——圆、方块、线段、函数图像、公式、文字、坐标轴、点,全部继承自Mobject。只要你想要在画面上显示一个“东西”,它本质上就是一个Mobject。
为什么叫“对象”而不是“图形”?因为manim的设计哲学是:任何一个屏幕上出现的东西,不仅包含它自身的几何属性(位置、大小、颜色、角度),还包含它与其他对象之间的关系(相对位置、父子关系、对齐方式)。这就像一个舞台上的演员,不仅有五官长相,还有站位和走位。Mobject的这套设计,让动画创建变得高度抽象和可组合。
看一下最基础的用法。比如:
from manim import Scene, Circle class MyScene(Scene): def construct(self): circle = Circle(color=BLUE) self.add(circle)这段代码创建一个蓝色的圆并添加到画面上。Circle()是Mobject的子类,它继承了父类的move_to、shift、scale、rotate等方法;也就是说,你可以这样:
circle.move_to(LEFT * 2) circle.scale(1.5) circle.rotate(PI / 4)操作对象的方式,本质上是在修改它的内部坐标和变换矩阵。这个思路贯穿manim的始终:你不需要关心像素级的绘制细节,你只需要用数学语言描述“把圆向左移两单位”“放大1.5倍”“旋转45度”。
2.2 坐标系统:manim的自定义坐标系
说到“向左移两个单位”,这里就必须讲清楚manim的坐标系。manim使用的是一个自定义的抽象坐标系,不是像素坐标系。默认的视野范围是X轴从-7到7,Y轴从-4到4(横纵比16:9的情况下)。原点位于画布正中央。这个设计非常“数学化”——它让你像在坐标系上画图一样摆放对象,而不是像在画布上用像素定位。
LEFT、RIGHT、UP、DOWN是manim预定义的单位向量:
LEFT = [-1, 0, 0] RIGHT = [1, 0, 0] UP = [0, 1, 0] DOWN = [0, -1, 0]所以circle.move_to(LEFT * 2)就是把圆移动到[-2, 0, 0]这个坐标。如果你用过matplotlib,你会发现它们之间有相似之处——都有坐标系的概念,但manim更进一步把“位置变换”做成了方法链,方便连续操作。
还有一个常用的坐标工具是np.array([x, y, 0]),例如:
dot = Dot(np.array([2, 1, 0]))这里第三维0是因为manim支持3D场景(ThreeDScene),但在2D场景里第三维恒为0。这一点新手很容易忽略,直接在move_to里传一个二元组会报错,必须传三维坐标。
2.3 子对象与组合:VGroup的威力
实际动画中你很少只操作一个孤立的图形,更多时候是把多个对象打包成一个整体,统一变换。manim为此提供了VGroup(Vectorized Group,向量组)。它本身也是一个Mobject,但内部可以容纳任意多个其他Mobject,并统一管理它们的相对位置和整体变换。
举个例子,画一个带标签的坐标点:
from manim import * class DotWithLabel(Scene): def construct(self): dot = Dot(np.array([1, 1, 0]), color=YELLOW) label = MathTex("A").next_to(dot, UR) group = VGroup(dot, label) self.add(group) self.play(group.animate.shift(RIGHT * 2))这里VGroup(dot, label)把点和标签捆绑在一起,之后对group做位移,标签和点就会同步移动,不会出现“点走了标签留在原地”的尴尬局面。这种组合能力,是manim能够搭建复杂数学场景的根基——你永远可以把小对象组织成大对象,再把大对象组织成更大的对象。
从实际项目经验看,我建议从一开始就养成分组思维。很多新手写动画时一个self.add()就把所有内容铺在画布上,后面对齐、移动时就会非常痛苦。正确做法是:想清楚这个画面里哪些对象是一个逻辑整体,把它们用VGroup包起来再操作。
3. 核心概念二:Animation——让对象“动”起来
3.1 静态add与动态play的区别
前面例子中的self.add(circle)只是把圆静态地放到画面里,动画里真正让画面动起来的是self.play()方法。manim的底层逻辑很简单:add是“让对象出现”,play是“让对象发生变化”,而这个变化过程不是瞬间完成的,而是按照一定帧率、一定插值方式逐步呈现的。
比如:
self.play(Create(circle))这一句的意思是:用“绘制”的方式让圆出现在画面上。你会看到一条圆弧从起点长出来、绕一圈闭合形成圆。这个动画是manim最常用的开场动画之一。Create就是Animation类的子类。manim内置了大量动画类,每个都是一种“变化方式”:
FadeIn/FadeOut:淡入淡出GrowFromCenter/GrowFromEdge:从中心/边缘生长出来Transform:将一个对象变换为另一个对象Write:模拟手写文字/公式Indicate:高亮强调某个对象MoveToTarget:将对象移动到预设的target位置Rotate:旋转Circumscribe:在对象周围画一个圈来强调
这些动画类就是你的“导演指令”,它们决定了动作的过程和节奏。这里有个重要的经验:Transform比ReplacementTransform更常用?不对,恰恰相反,它们两者之间的区别是新手最容易混淆的——Transform在执行完后,原对象会被替换成目标对象,但不会保留原对象在场景中的引用;ReplacementTransform则直接把原对象替换掉并把引用交给新对象。如果你在动画后还要继续操作“变换后的对象”,用ReplacementTransform会更顺手。
3.2 动画参数:时长、延迟与速率函数
每个Animation都接受一些通用参数,这可能是你写出“有灵气”动画的关键。
run_time:动画持续时间,单位秒。默认是1秒。lag_ratio:延迟比率。取值在0到1之间,表示“后一个子动画相对前一个子动画的延迟时间与单个子动画时长的比例”。比如对一组对象逐个FadeIn,lag_ratio=0表示同时出现,lag_ratio=1表示一个接一个出现。rate_func:速率函数,控制动画过程中对象变化的速度曲线。
第三个参数最有意思。manim默认的速率函数是smooth,它的效果是“慢-快-慢”,有点像汽车起步加速再减速停车,视觉上非常自然。如果你改成linear,动画就保持匀速,视觉上会显得机械。如果你想要“快-慢”的节奏(比如对象快速进场然后缓缓停下),可以用rate_func=there_and_back或者自定义函数。
这里分享一个我经常用的技巧:想让某个重点公式被“注视”时,不要只用一个简单的FadeIn,而是先用scale方法让公式稍微放大,再用FadeIn配合rate_func=linear,最后用Indicate做一个短暂的“闪一下”。三个动作组合起来,观众注意力自然会被引导到公式上。
3.3 等待机制:self.wait()的重要作用
很多新手在写动画时容易忽略self.wait()。它的作用是让画面停留在当前状态一段时间,不做任何变化。为什么需要它?因为人眼需要时间消化内容。一段数学动画如果没有停顿,全程都在动,观众很快就会视觉疲劳,更糟的是根本看不清你展示的重点。
我的习惯是:在一段关键推导前后各加至少1秒的wait。比如展示一个公式时,self.play(Write(formula), run_time=2),紧接着self.wait(0.5)让观众扫一眼,如果需要讲解(配合旁白),则直接self.wait(2)。这个细节决定了你的视频是“有呼吸感”还是“像赶火车”。
3.4 多动画并发:关键参数
manim支持多个动画同时播放,写法有几种:
# 写法一:多个动画同时执行 self.play(Create(circle), Write(label)) # 写法二:使用LaggedStart让多个动画错峰执行 self.play(LaggedStart( Create(circle), Write(label), lag_ratio=0.3 ))第一种写法下,circle和label是同时开始、同时结束的。第二种写法则让label比circle晚一丢丢开始。这个微小的时序差,有时候能把“机械感”变成“层次感”。我在做多元素演示时,特别喜欢用LaggedStart配合lag_ratio=0.2左右,让每个元素像多米诺骨牌一样依次出现,整体看起来很舒服。
4. 核心概念三:Scene——舞台与剧本
4.1 Scene的角色定位
Scene是manim中“场景”的基类。一个Scene对应一段独立的动画视频。你写一个类继承Scene,在这个类的construct方法里写你所有的代码逻辑——添加对象、播放动画、等待、清除,等等。manim的理念是:一个Python文件里可以定义多个Scene,每个Scene最终会渲染成独立的视频文件。这在制作系列视频时特别方便:你可以把同一套数学逻辑的不同演示分别写在不同的Scene里,然后逐个渲染。
construct方法名是固定的,manim的入口就是找到你指定的Scene类,并调用它的construct方法。我最早写的时候犯过错误:把方法名写成了__init__,结果渲染出来是黑屏——因为manim根本不认识那个方法。
4.2 从零写一个完整Scene
先看一个最小可运行的Scene:
from manim import * class FirstScene(Scene): def construct(self): title = Text("Hello, manim!", font_size=48) self.play(Write(title)) self.wait(1)在命令行里这样运行:
manim -pql first_scene.py FirstScene格式是manim [选项] 文件名 场景类名。-p表示渲染结束后自动打开预览文件,-ql表示使用低画质快速渲染(quality low)。低画质渲染是用来调试的利器,每秒帧数低、分辨率低,速度能快好几倍。正式出片时使用-qh(high quality)、-qk(4K)等等。
完整的一次渲染流程大概是:
- manim启动,导入Python文件。
- 找到你指定的Scene类。
- 实例化Scene,调用
construct()方法。 - 在
construct()执行期间,manim会把你的self.play()调用记录为“动画指令序列”。 construct()执行完毕后,manim根据记录的动画指令序列进行渲染,生成视频帧。- 把这些帧编码成视频文件,默认输出到
media/videos/文件名/480p15/目录下。
这里有一个新手普遍困惑的点:manim并不是一边执行self.play()一边录制屏幕,而是先记录指令、再统一渲染。这意味着在construct()里写的任何“非动画”代码(比如print、普通的循环计算)都是在渲染之前就执行完了,只有self.add()和self.play()这类“场景操作”才会变成最终的画面内容。
4.3 Scene的常用方法
我整理一下我日常写Scene时最常用到的方法:
add(*mobjects)/remove(*mobjects):直接添加或移除对象,瞬时生效,没有动画过渡。play(*animations, run_time=1, rate_func=smooth):播放一个或多个动画。wait(duration=1):停留等待。clear():移除所有对象。embed():进入交互模式,在渲染时打开浏览器窗口,允许你逐帧调试(社区版功能,需要额外依赖,我后文会说明)。set_camera_orientation(ThreeDScene专用):设置3D场景的相机视角。
一个典型的Scene结构通常是这样的:
class Example(Scene): def construct(self): # 1. 创建对象 axes = Axes() graph = axes.plot(lambda x: np.sin(x)) # 2. 呈现对象 self.play(Create(axes)) self.play(Create(graph), run_time=2) self.wait(0.5) # 3. 变换或强调 self.play(Indicate(graph)) self.wait(0.5) # 4. 收尾 self.play(FadeOut(axes), FadeOut(graph))这个结构的核心思想是“建对象-呈现-强调-收尾”,类似写文章的开头-正文-结尾。你把每个Scene当成一个小节目来编排,节奏感自然就出来了。
4.4 多个Scene如何组织和复用
写多了你就会发现,很多代码是重复的——比如坐标轴的样式、文字的字体、动画的run_time。我的做法是把公共配置抽离成常量或函数:
AXES_CONFIG = { "x_range": [-4, 4, 1], "y_range": [-2, 2, 1], "axis_config": {"include_numbers": True}, } def create_math_axes(): return Axes(**AXES_CONFIG)然后在每个Scene里调用create_math_axes()。另外manim支持Scene之间的“叠加渲染”——你可以在一个Scene里调用self.next_scene或者继承Scene的子类来复用逻辑,但最直接的方式还是把公共部分抽成工具函数。
5. 上手实操:用基本概念做一个可复现的动画
5.1 案例目标:用动画解释勾股定理
光讲概念容易飘,我们来动手做一个非常经典、也非常适合用来练手基础概念的案例:用manim动画直观展示勾股定理。
演示思路:
- 画一个直角三角形,三条边分别向外画正方形。
- 让两条直角边上的正方形“切碎”成小方块,然后“移动”到斜边正方形里。
- 观众会看到面积相等,自然而然地理解“直角边的平方和等于斜边的平方”。
这个动画用到的基本概念有:Polygon(多边形)、Square(正方形)、Transform(变换)、Create(创建)、ReplacementTransform等等。我们一步步来。
5.2 第一步:创建三角形和三个正方形
from manim import * class Pythagoras(Scene): def construct(self): # 设定三个顶点:直角在左下角 A = np.array([0, 0, 0]) B = np.array([3, 0, 0]) C = np.array([0, 2, 0]) triangle = Polygon(A, B, C, color=WHITE) # 三条边上的正方形 square_AB = Square(side_length=3).move_to([1.5, -1.5, 0]) square_AC = Square(side_length=2).move_to([-1, 1, 0]) # 斜边上的正方形,需要先计算斜边方向和长度 BC_length = np.linalg.norm(B - C) square_BC = Square(side_length=BC_length) # 旋转并移动到合适位置,这里简化处理 square_BC.rotate(-np.arctan2(2, 3)).move_to( np.array([1.5, 1, 0]) ) self.play(Create(triangle)) self.play( Create(square_AB), Create(square_AC), Create(square_BC), ) self.wait(1)先别管这个版本的对齐是否完美,它演示的内容是:用Polygon画三角形,用Square画正方形,用np.linalg.norm计算斜边长度,用rotate旋转对象到正确朝向。在你继续之前,请你先在本地跑一次这个代码,看看结果对不对。如果你发现正方形的边没有和三角形的边完全贴合——这是正常的,因为旋转角度计算比较粗糙,我后面会展示更精确的写法。
5.3 第二步:把“面积移动”做成动画
核心动画是把两个小正方形“切”成若干小块,再移动到斜边正方形上。manim里没有一个内置的“自动切方块”功能,但我们可以用非常接近的做法:手动把两个正方形的面积分解成若干小正方形,然后分别Transform到斜边正方形对应的区域。
为了简化,我这里只做“视觉抽象”版本的演示:
# 把两个小正方形分别缩放成2x2和3x3的网格,然后移动到斜边正方形中 blocks = VGroup() for i in range(3): # 3列 for j in range(3): # 3行 block = Square(side_length=1).move_to( np.array([1.5 + i - 1, -1.5 + j - 1, 0]) ) blocks.add(block) self.play( ReplacementTransform(square_AB, blocks), run_time=2 ) self.wait(1)这一段只演示了把AB边上的正方形变换成9个小方块。实际要做出“移动并填充”的效果,还需要计算每个小方块移动到斜边正方形内对应位置的坐标。计算量不大,但写起来比较繁琐。这里我更想展示的是:你不需要一次写完整个复杂动画,你只需要验证每一步的基本概念能按预期工作。
5.4 第三步:完整代码中的细节修正
在实际运行上面的代码时,你大概率会遇到几个问题:
- 正方形和三角形没有贴合。原因是
Square的move_to默认移动的是正方形的中心点,而你在计算时容易混淆中心坐标和角点坐标。一个更稳妥的做法是:用Polygon的顶点坐标直接推导正方形的位置和旋转角度。 Create(square)画出的只是正方形的边框,它不会自动填充内部颜色。如果你想填充颜色,需要设置fill_opacity参数,例如Square(side_length=1, fill_opacity=0.3)。- 动画中位移和旋转同时进行时,Transform的结果可能和预想不同。原因在于manim的
Transform默认使用对象的锚点信息进行插值,当源对象和目标对象的锚点数不一致时,变换过程会不太平滑。解决办法是使用ReplacementTransform或者在变换前先让两个对象具有相同数量的锚点。
这里我强烈建议你学会调试技巧:用self.add(*mobjects)而不是self.play(Create(...))来快速预览对象的位置是否摆对。因为add是瞬时的,你一眼就能看到静态布局是否合理,调整完坐标后再换成play。
5.5 预期输出效果描述
当你把所有步骤完成后,动画大概的连贯效果是这样:
- 画面中央出现一个白色直角三角形。
- 三条边外同时“长”出三个正方形。
- 直角边上的两个正方形(一个2x2,一个3x3)从实心方块碎成小方格。
- 这些小方格依次飞向斜边上的正方形,并填入其中。
- 最终斜边上的正方形被完整填充,旁边出现一行文字
3^2 + 2^2 = 13。
这个过程如果配上旁白,基本就是一个合格的教学视频片段。如果只想快速看效果,建议使用-pm(medium quality)渲染,或者加--disable_caching参数避免因为旧缓存导致看不清楚修改后的效果。
6. 常见问题排查与避坑指南
6.1 运行时报错的类型与解决方法
我整理了我眼中新手最常踩的坑,按出现频率排序如下。
AttributeError: module 'manim' has no attribute 'Square'
这个报错几乎都是因为你的文件名和
manim模块重名了。比如你把脚本保存为manim.py,Python导入时就会优先导入你自己的文件,而不是真正的manim库。解决方法是把脚本改名,比如test_manim.py,同时确保当前目录下没有其他manim.py文件。ValueError: 坐标维度不对
前面提过,manim的坐标必须是三维的
np.array([x, y, 0])。如果你传入了(x, y)或Python列表的二维坐标,会触发维度相关的错误。写的时候要养成加第三维的习惯。渲染很慢 / 进度条卡住
社区版manim默认会对动画进行缓存,如果修改了代码但场景名没变,有时会复用旧的缓存,导致你看到的渲染结果不是最新代码的结果。加
--disable_caching参数可以强制重新渲染。另外如果你是首次渲染高画质,慢是正常的——manim每一帧都要通过Cairo或OpenGL绘制,复杂场景动辄几百帧,30秒的动画可能需要几分钟到十几分钟。RuntimeError: Manim is not being run from within a Scene's construct method
这个报错一般是因为你在
construct之外调用了只有Scene实例才能调用的方法(比如self.play)。所有动画指令都必须写在construct方法里,这是manim的硬性规定。
6.2 调试技巧:为什么你的对象位置和想象的不一样
数学家在纸上画图可以用眼睛微调,用代码写动画则必须靠精确坐标。但人脑对屏幕坐标系的理解未必准确,于是调试就成了重要技能。
我的调试三板斧:
- 使用
self.add(NumberPlane())先显示一个坐标平面,方便你直观看到对象的位置。调试完再删掉这行。 - 对象旁边添加一个
Text标签,比如Text("(2, 1)").next_to(dot, UP),实时显示对象的坐标。 - 使用低分辨率快速渲染:
manim -pql file.py ClassName,加上-p预览,通常几秒就能看到效果。
另外一个很实用的小技巧是:在多个self.play之前,先用self.add把所有对象一次性显示出来,检查静态布局。这样可以避免你在动画执行中途才发现“哦,原来这个方块一开始就偏了”。
6.3 manim官网与中文资料的选择
有关manim的知识体系,我最后想多说几句。manim官网(docs.manim.community)是目前最权威的文档来源,它包含完整的API说明、示例代码和教程。很多中文博客和视频教程都有时效性问题,因为manim社区版迭代速度非常快,API变动也频繁,旧教程里的代码在新的manim版本下可能直接报错。因此遇到问题时,第一优先参考官网的文档,尤其是manim的reference部分。
如果你搜索“manim官网中文版”,你会发现一些国内镜像站或其他翻译项目。这些可以作为入门参考,但翻译版本往往滞后于官方文档,API的变更可能没有及时同步。我的建议是:中文资料用来理解概念,官方文档用来查询函数签名和参数,两者结合使用。遇到具体报错时,把报错信息原样复制到搜索引擎通常能得到更精准的答案。
7. 从基本概念到下一步:你的第一个“生产级”动画应该怎么规划
7.1 规划动画脚本的思路
很多新手拿到manim后容易陷入“炫技”陷阱:什么动画都要用复杂的变换,什么都想动起来。但真正好的数学动画,是克制的。每个动作都要有目的——是为了引导视线、表达关系,还是为了强调结论?如果没有目的,宁可不做动画。
我的规划习惯是:
- 先在纸上画出每一幕的“关键帧”——也就是画面最重要的那个静态状态。
- 确定每一幕之间的转场方式:淡入淡出、平移镜头、还是直接切换?
- 估算每段动画的时间。一般来说,一个关键结论出现后,至少要留1秒停顿;一段复杂的变换过程,不要超过3秒,否则观众会觉得拖沓。
- 最后才写代码。写代码的过程应该是“按剧本执行”,而不是边写边想剧本。
7.2 让动画节奏更舒服的几点心得
结合我自己的使用经验,有几点偏好在写了好几段视频之后才慢慢形成的:
- 重要的公式或结论,用
Write而不是FadeIn。Write是模拟手写,天然引导观众逐字阅读,注意力更集中。 - 强调时优先用
Indicate而不是Scale。Indicate会在对象周围画一个短暂的光圈,视觉上更“轻”,不会打断画面布局。 - 画面元素不要过多。一个Scene里同时存在的对象尽量控制在5个左右。超过这个数量,观众就会失去焦点。
- 背景色默认是黑色,不一定适合所有场景。如果你的视频需要用在课件或印刷材料里,可以考虑改用白色背景。manim里设置背景色很简单,
self.camera.background_color = WHITE,但要注意文字和图形的颜色也要相应调整,避免白底白字的尴尬。
7.3 下一步学习方向
这篇讲完基本概念后,下一步我打算聊聊manim中更进阶的主题:
- 坐标轴与函数图像:
Axes、plot、implicit_function,这是数学动画里最刚需的部分。 - 公式排版与
MathTex:如何用LaTeX语法创建漂亮公式,如何对公式中的局部进行高亮和变换。 - 相机控制与镜头移动:
MovingCameraScene,实现“镜头跟随”,适合做长推导。 - 3D场景:
ThreeDScene,旋转体、立体几何的演示。
顺便说一句,manim的学习曲线并不平缓,你可能需要花几天时间熟悉它的API风格,再花几周时间才能独立做出满意的作品。但如果你的目标只是“做出能讲清楚一个数学概念的短视频”,那它绝对是值得投入的工具。我从一个只会print("hello")的状态,到做出第一个有模有样的动画,大约用了一周。所以,别急着上手复杂特效,先从今天这篇文章里的基本概念练起,把一个圆、一个三角形摆好、动起来,你已经超过了90%的路人。