Manim 的基本构成

285 阅读13分钟

本文档介绍了 Manim 的基本构成,并为你提供制作自己的动画视频所需的所有必要工具。

本质上,Manim 提供了三种可以协同使用的概念,用于制作数学动画:数学对象(简称 mobject)、动画(Animation) 和 场景(Scene)。在接下来的章节中我们将看到,这三个概念在 Manim 中分别通过三个类实现:MobjectAnimationScene 类。

Mobjects

Mobjects 是所有 Manim 动画的基本构建块。每一个继承自 Mobject 的类都表示可以在屏幕上显示的对象。例如,简单的图形如 Circle(圆)、Arrow(箭头)和 Rectangle(矩形)都是 mobject。更复杂的结构如 Axes(坐标轴)、FunctionGraph(函数图像)或 BarChart(柱状图)也都是 mobject。

如果你尝试在屏幕上显示一个 Mobject 实例,你只会看到一个空的框架。这是因为 Mobject 类是所有其他 mobject 的抽象基类,它本身没有任何预定义的可视形状。它只是可以显示的事物的“骨架”。因此,你很少会直接使用普通的 Mobject 实例;相反,你会更可能创建其派生类的实例。其中一类派生类是 VMobject,其中 V 表示“矢量化的 Mobject”(Vectorized Mobject)。本质上,vmobject 是使用矢量图形进行显示的 mobject。大多数情况下,你将处理的是 vmobject,但为了表述更通用,我们仍会继续用“mobject”一词来泛指那些可以在屏幕上显示的图形对象。

任何可以在屏幕上显示的对象都是 mobject,即使它不一定具有数学性质。要查看源自 Mobject 的类的示例,请参阅几何模块。这些类中的大多数实际上也是从 VMobject 派生的。

创建和显示 Mobjects

通常在一个 Manim 脚本中的所有代码都会放在一个 Scene 类的 construct() 方法内。要在屏幕上显示一个 mobject,可以调用包含该 mobject 的 Scene 类的 add() 方法。这是在不进行动画的情况下将 mobject 显示在屏幕上的主要方法。若要从屏幕上移除一个 mobject,只需简单地调用包含它的 Scene 类的 remove() 方法。

下面是一个具体的例子:

from manim import *

class CreatingMobjects(Scene):
    def construct(self):
        # 创建一个圆
        circle = Circle()
        
        # 将圆添加到场景中并显示
        self.add(circle)
        self.wait(1)  # 等待1秒
        
        # 从场景中移除圆
        self.remove(circle)
        self.wait(1)  # 再等待1秒

在这个例子中,首先创建了一个 Circle 对象,并通过 self.add(circle) 将其添加到场景中显示出来。接着,使用 self.wait(1) 让动画暂停一秒以便我们可以看到这个圆。之后,通过 self.remove(circle) 从场景中移除了这个圆,并再次等待一秒以展示移除后的效果。这种方法非常适合用于控制哪些对象应在何时出现在屏幕上。

放置 Mobjects

让我们定义一个新的 Scene 叫做 Shapes 并向其中 add() 一些 mobjects。这个脚本生成一个静态的画面,显示一个圆、一个正方形和一个三角形:

from manim import *

class Shapes(Scene):
    def construct(self):
        circle = Circle()
        square = Square()
        triangle = Triangle()

        # 将圆向左移动
        circle.shift(LEFT)
        # 将正方形向上移动
        square.shift(UP)
        # 将三角形向右移动
        triangle.shift(RIGHT)

        self.add(circle, square, triangle)
        self.wait(1)

默认情况下,mobjects 在创建时放置在坐标中心(即原点)。它们也被赋予了一些默认颜色。此外,Shapes 场景通过使用 shift() 方法来放置 mobjects。正方形从原点开始向上移动一个单位,而圆和三角形分别向左和向右移动一个单位。

注意
与其它图形软件不同,Manim 将坐标的中心置于屏幕的中心。正垂直方向是向上,正水平方向是向右。参见常量模块中定义的 ORIGINUPDOWNLEFTRIGHT 等常量。

还有许多其他方式可以将 mobjects 放置在屏幕上,例如 move_to()next_to()align_to()。下一个场景 MobjectPlacement 使用了这三种方法:

from manim import *

class MobjectPlacement(Scene):
    def construct(self):
        circle = Circle()
        square = Square()
        triangle = Triangle()

        # 将圆放置在距离原点左侧两个单位的位置
        circle.move_to(LEFT * 2)
        # 将正方形放置在圆的左侧
        square.next_to(circle, LEFT)
        # 将三角形的左边与圆的左边对齐
        triangle.align_to(circle, LEFT)

        self.add(circle, square, triangle)
        self.wait(1)
  • move_to() 方法使用绝对单位(相对于 ORIGIN 测量)。
  • next_to() 方法使用相对单位(相对于作为第一个参数传递的 mobject 测量)。
  • align_to() 方法使用 LEFT 不是作为测量单位,而是确定用于对齐的边。mobject 边界的坐标是通过它周围的一个虚拟边界框确定的。

在 Manim 中,许多方法可以链式调用。例如下面两行代码:

square = Square()
square.shift(LEFT)

可以替换为一行代码:

square = Square().shift(LEFT)

技术上来说,这是可能的,因为大多数方法调用都会返回修改后的 mobject。

给 Mobjects 添加样式

下面的场景更改了 mobjects 的默认美学设置。

python
深色版本
from manim import *

class MobjectStyling(Scene):
    def construct(self):
        circle = Circle().shift(LEFT)
        square = Square().shift(UP)
        triangle = Triangle().shift(RIGHT)

        # 设置圆的边框颜色为绿色,宽度为20
        circle.set_stroke(color=GREEN, width=20)
        # 设置正方形的填充颜色为黄色,不透明度为1.0(完全不透明)
        square.set_fill(YELLOW, opacity=1.0)
        # 设置三角形的填充颜色为粉色,不透明度为0.5(半透明)
        triangle.set_fill(PINK, opacity=0.5)

        self.add(circle, square, triangle)
        self.wait(1)

这个场景使用了两个主要函数来改变 mobject 的视觉样式:set_stroke()set_fill()。前者更改 mobject 边界的视觉样式,而后者更改内部的样式。默认情况下,大多数 mobjects 的内部是完全透明的,因此你必须指定 opacity 参数以显示颜色。不透明度为 1.0 表示完全不透明,而 0.0 表示完全透明。

  • set_stroke() :用于设置 mobject 边界的颜色和宽度。
  • set_fill() :用于设置 mobject 内部的颜色和不透明度。

注意
只有 VMobject 的实例实现了 set_stroke()set_fill() 方法。Mobject 的实例则实现 set_color() 方法。绝大多数预定义类都继承自 VMobject,因此通常可以假设你可以使用 set_stroke()set_fill() 方法。

通过这种方式,你可以轻松地调整 mobjects 的外观,使其更符合你的设计需求。例如,你可以改变形状的颜色、边框宽度以及填充的不透明度等属性。

Mobject 的屏幕显示顺序

接下来的场景与上一节中的 MobjectStyling 场景完全相同,除了其中一行代码不同。

python
深色版本
from manim import *

class MobjectZOrder(Scene):
    def construct(self):
        circle = Circle().shift(LEFT)
        square = Square().shift(UP)
        triangle = Triangle().shift(RIGHT)

        circle.set_stroke(color=GREEN, width=20)
        square.set_fill(YELLOW, opacity=1.0)
        triangle.set_fill(PINK, opacity=0.5)

        # 注意这里的添加顺序
        self.add(triangle, square, circle)
        self.wait(1)

这里唯一的区别(除了场景名称外)是 mobjects 被添加到场景中的顺序。在 MobjectStyling 中,我们按 self.add(circle, square, triangle) 的顺序添加它们,而在 MobjectZOrder 中则是按 self.add(triangle, square, circle) 的顺序添加。

如你所见,add() 方法中参数的顺序决定了 mobjects 在屏幕上显示的顺序,左边的参数会被置于背景层,而右边的参数则会覆盖在前面。


动画

Manim 的核心是动画。通常,你可以通过调用 play() 方法为你的场景添加动画。

from manim import *

class SomeAnimations(Scene):
    def construct(self):
        square = Square()

        # 一些动画显示 mobjects...
        self.play(FadeIn(square))

        # ...一些动画移动或旋转 mobjects...
        self.play(Rotate(square, PI/4))

        # 一些动画从屏幕上移除 mobjects
        self.play(FadeOut(square))

        self.wait(1)

简而言之,动画是介于两个 mobjects 之间的插值过程。例如,FadeIn(square) 从一个完全透明的 square 开始,结束于一个完全不透明的版本,通过逐渐增加透明度来进行插值。相反地,FadeOut 则是从完全不透明插值到完全透明。另一个例子是 Rotate,它以传入的 mobject 作为起始状态,并以旋转一定角度后的同一对象作为结束状态,在这个过程中插值的是 mobject 的角度而不是透明度。

通过这种方式,你可以创建各种各样的动画效果来动态展示和操作 mobjects,使得你的场景更加生动和吸引人。无论是淡入淡出、移动、旋转还是其他类型的动画,都可以通过简单的函数调用来实现。

动画化方法(Animating Methods)

任何可以更改的 mobject 属性都可以被动画化。事实上,任何能改变 mobject 属性的方法都可以通过 animate() 被用作动画。

from manim import *

class AnimateExample(Scene):
    def construct(self):
        # 创建一个红色实心正方形并添加到场景中
        square = Square().set_fill(RED, opacity=1.0)
        self.add(square)

        # 动画:颜色从红色变为白色
        self.play(square.animate.set_fill(WHITE))
        self.wait(1)

        # 动画:同时移动位置并向右旋转 π/3 弧度
        self.play(square.animate.shift(UP).rotate(PI / 3))
        self.wait(1)

animate() 是所有 mobject 的属性,它会将后续调用的方法变成动画形式。例如: square.set_fill(WHITE) 立即设置填充颜色;square.animate.set_fill(WHITE) 则是将这个变化过程以动画方式呈现。


动画持续时间(Animation Run Time)

默认情况下,play() 中的每个动画持续 1 秒。你可以使用 run_time 参数来自定义动画时长:

self.play(square.animate.shift(RIGHT), run_time=2)

这样动画将持续 2 秒完成。


自定义动画(Creating a Custom Animation)

尽管 Manim 拥有许多内置的动画,但你有时会遇到需要将一个 Mobject 从一种状态平滑过渡到另一种状态的情况。如果你遇到了这种情况,就可以定义自己的自定义动画。你可以通过继承 Animation 类并重写其 interpolate_mobject() 方法来开始。

interpolate_mobject() 方法会接收一个 alpha 参数,该参数从 0 开始,并在动画过程中不断变化。因此,你只需根据 interpolate_mobject 方法中的 alpha 值,在 Animation 内部操作 self.mobject 即可。这样你就能获得 Animation 提供的所有好处,例如以不同的持续时间播放动画,或使用不同的速率函数。

假设你从一个数字开始,并希望创建一个 Transform 动画将其转换为目标数字。你可以使用 FadeTransform来实现,它会使起始数字淡出并使目标数字淡入。但当我们思考如何将一个数字变换为另一个数字时,一种更直观的方式是通过平滑地递增或递减数值。Manim 提供了一项功能,允许你通过定义自己的自定义动画来定制这种行为。

你可以从创建自己的 Count 类开始,该类继承自 Animation。这个类的构造函数可以接受三个参数:一个 DecimalNumber Mobject、起始值和结束值。构造函数会将该 DecimalNumber Mobject 传递给父类构造函数(在此情况下即 Animation 的构造函数),并设置起始值和结束值。

你需要做的唯一一件事就是定义你希望动画每一步显示的效果。Manim 会根据视频的帧率、速率函数以及动画的播放时长,将当前动画进度对应的 alpha 值传入 interpolate_mobject() 方法中。alpha 参数的取值范围是 0 到 1,表示当前播放动画的进度。例如,0 表示动画的开始,0.5 表示动画进行到一半,1 表示动画结束。

对于 Count 动画来说,你只需要想办法确定在给定的 alpha 值下应显示的数值,并在 Count 动画的 interpolate_mobject() 方法中设置该值。假设你从 50 开始递增,直到动画结束时 DecimalNumber 达到 100。

  • 如果 alpha 是 0,你希望数值为 50。
  • 如果 alpha 是 0.5,你希望数值为 75。
  • 如果 alpha 是 1,你希望数值为 100。

一般来说,你会从起始值开始,根据 alpha 值逐步加上一部分增量。因此,在每一步计算要显示数值的逻辑就是:50 + alpha * (100 - 50)。一旦你为 DecimalNumber 设置了计算后的值,就完成了。

当你定义好 Count 动画后,就可以在你的 Scene 中以任意时长、对任意 DecimalNumber 使用任意速率函数来播放它。

from manim import *

class Count(Animation):
    def __init__(self, number: DecimalNumber, start: float, end: float, **kwargs) -> None:
        # 将 DecimalNumber 对象作为动画的目标对象
        super().__init__(number, **kwargs)
        self.start = start
        self.end = end

    def interpolate_mobject(self, alpha: float) -> None:
        # 根据 alpha 计算当前值并更新 DecimalNumber
        value = self.start + alpha * (self.end - self.start)
        self.mobject.set_value(value)

class CountingScene(Scene):
    def construct(self):
        # 创建一个大号白色 DecimalNumber 并居中显示
        number = DecimalNumber().set_color(WHITE).scale(5)
        
        # 添加 updater 确保数值变化后仍保持居中
        number.add_updater(lambda num: num.move_to(ORIGIN))

        self.add(number)
        self.wait()

        # 播放动画:4 秒内从 0 平滑增加到 100,使用线性速率函数
        self.play(Count(number, 0, 100), run_time=4, rate_func=linear)

        self.wait()

使用 Mobject 的坐标

Mobjects 包含定义其边界的点。这些点可以用于将其他 mobjects 相对地添加到它们之上,例如通过 get_center()get_top()get_start() 等方法。

以下是一些重要坐标的示例:

from manim import *

class MobjectExample(Scene):
    def construct(self):
        p1 = [-1, -1, 0]
        p2 = [ 1, -1, 0]
        p3 = [ 1,  1, 0]
        p4 = [-1,  1, 0]
        a  = Line(p1, p2).append_points(Line(p2, p3).points).append_points(Line(p3, p4).points)
        
        point_start  = a.get_start()
        point_end    = a.get_end()
        point_center = a.get_center()

        self.add(Text(f"a.get_start() = {np.round(point_start,2).tolist()}", font_size=24).to_edge(UR).set_color(YELLOW))
        self.add(Text(f"a.get_end() = {np.round(point_end,2).tolist()}", font_size=24).next_to(self.mobjects[-1], DOWN).set_color(RED))
        self.add(Text(f"a.get_center() = {np.round(point_center,2).tolist()}", font_size=24).next_to(self.mobjects[-1], DOWN).set_color(BLUE))

        self.add(Dot(a.get_start()).set_color(YELLOW).scale(2))
        self.add(Dot(a.get_end()).set_color(RED).scale(2))
        self.add(Dot(a.get_top()).set_color(GREEN_A).scale(2))
        self.add(Dot(a.get_bottom()).set_color(GREEN_D).scale(2))
        self.add(Dot(a.get_center()).set_color(BLUE).scale(2))
        self.add(Dot(a.point_from_proportion(0.5)).set_color(ORANGE).scale(2))
        self.add(*[Dot(x) for x in a.points])
        self.add(a)

将 Mobject 转换为另一个 Mobject

你也可以将一个 mobject 转换为另一个 mobject,像这样:

from manim import *

class ExampleTransform(Scene):
    def construct(self):
        self.camera.background_color = WHITE
        m1 = Square().set_color(RED)
        m2 = Rectangle().set_color(RED).rotate(0.2)
        self.play(Transform(m1,m2))

Transform 函数会将前一个 mobject 的点映射到下一个 mobject 的点。这可能会导致一些奇怪的行为,例如:当一个 mobject 的点按顺时针排列,而另一个 mobject 的点按逆时针排列时。在这种情况下,可以使用 flip 函数,并通过 numpyroll 函数重新排列点的顺序来改善动画效果:

from manim import *

class ExampleRotation(Scene):
    def construct(self):
        self.camera.background_color = WHITE
        m1a = Square().set_color(RED).shift(LEFT)
        m1b = Circle().set_color(RED).shift(LEFT)
        m2a = Square().set_color(BLUE).shift(RIGHT)
        m2b = Circle().set_color(BLUE).shift(RIGHT)

        points = m2a.points
        points = np.roll(points, int(len(points)/4), axis=0)
        m2a.points = points

        self.play(Transform(m1a, m1b), Transform(m2a, m2b), run_time=1)

场景

Scene 类是 manim 的连接组织。每一个 mobject 必须添加到一个场景中才能显示,或者从场景中移除以停止显示。每一个动画必须由一个场景播放,并且每一个没有动画发生的时间间隔都是通过调用 wait() 来确定的。你视频中的所有代码必须包含在一个继承自 Scene 类的 construct() 方法中。最后,如果需要同时渲染多个场景,单个文件可以包含多个 Scene 子类。