深入探究Manim的内部实现#

作者: Benjamin Hackl

免责声明

这份指南反映的是Manim v0.16.0版本的状态,主要涉及Cairo渲染器。最新版本的Manim情况可能会有所不同;如果有重大变化,我们将在下面添加说明。

介绍#

如果Manim表现出您期望的行为,它可以成为一个非常出色的库。但不幸的是,这并不总是情况(如果您已经尝试过一些manimation,可能已经有所了解)。为了了解问题出在哪里,有时必须深入研究库的源代码,但为了做到这一点,您需要知道从哪里开始挖掘。

本文旨在作为渲染过程的某种生命线。我们旨在适当地描述Manim读取您的场景代码并生成相应动画时发生的事情的详细程度。在整篇文章中,我们将重点关注以下示例::

from manim import *

class ToyExample(Scene):
    def construct(self):
        orange_square = Square(color=ORANGE, fill_opacity=0.5)
        blue_circle = Circle(color=BLUE, fill_opacity=0.5)
        self.add(orange_square)
        self.play(ReplacementTransform(orange_square, blue_circle, run_time=3))
        small_dot = Dot()
        small_dot.add_updater(lambda mob: mob.next_to(blue_circle, DOWN))
        self.play(Create(small_dot))
        self.play(blue_circle.animate.shift(RIGHT))
        self.wait()
        self.play(FadeOut(blue_circle, small_dot))

在我们详细了解或查看此场景的渲染输出之前,让我们先口头描述一下这个 manimation中发生的事情。 在construct 方法的前三行中,初始化了一个Square正方形和一个 Circle圆形 ,然后将正方形添加到场景中。 渲染输出的第一帧应该显示一个橙色的正方形。

然后,实际的动画效果发生了:正方形首先变换成圆形, 然后创建了一个Dot点 (当它首次添加到场景中时,您猜这个点位于哪里?回答这个问题已经需要详细了解渲染过程了)。 这个点有一个更新器附加到它上面,当圆形向右移动时,点也会跟随它移动。最后,所有的 mobject 都会faded out(淡出)。

实际渲染这段代码会产生以下视频:

对于这个示例,输出(幸运的是)与我们的预期相符。

概述#

由于这篇文章包含了很多信息,因此以下简要概述了接下来各章节的内容,从高层次上进行了讨论。

  • Preliminaries预备知识:在本章中,我们将揭示准备场景进行渲染的所有步骤, 直到运行用户重写的construct方法的那一刻。 这包括简要讨论使用Manim的CLI与其他渲染方式(例如通过Jupyter notebooks或通过调用Scene.render()方法在您的Python脚本中自行进行渲染) 的区别。

  • Mobject 初始化 :在第二章中,我们深入探讨了创建和处理Mobjects, 即应该在场景中显示的基本元素。我们将讨论Mobject基类, 以及基本上有三种不同类型的Mobjects,然后讨论其中最重要的矢量化Mobjects。 特别是,我们将描述内部点数据结构, 该数据结构控制着负责将矢量化Mobject绘制到屏幕上的机制如何设置相应的贝塞尔曲线。 最后,我们将介绍Scene.add(),这是控制哪些mobjects应该被渲染的记录机制。

  • 动画和渲染循环 :最后,在最后一章中, 我们将通过实例化Animation对象(保存有关Mobjects在渲染循环运行时如何修改的蓝图)来详细介绍动画, 然后调查臭名昭著的Scene.play()调用。我们将看到Scene.play()调用中有三个相关部分; 第一部分处理和准备传递的动画和关键字参数,接下来是实际的“渲染循环”,在此循环中, 库通过时间线逐帧渲染。最后一部分对一段短视频进行一些后处理(“部分电影文件”)和清理,为下一次Scene.play()调用做准备。 最终,在运行完整个Scene.construct()之后,库将这些部分电影文件合并为一个视频。

好的,那么让我们开始吧。

Preliminaries预备知识#

导入库 #

不管您是通过manim -qm -p file_name.py ToyExample这样的命令运行系统来渲染场景,还是通过像下面这样的Python脚本直接渲染场景:

with tempconfig({"quality": "medium_quality", "preview": True}):
    scene = ToyExample()
    scene.render()

或者您是在 Jupyter notebook 中渲染代码,您仍然会告诉Python解释器导入库。通常用于这样做的模式是:

from manim import *

这种导入方法一般来说存在争议,但它会导入许多随库提供的类和函数,并在全局名称空间中使它们可用。 我可以明确的说,并不会导入导入all 类和函数: Manim使用了了 Section 6.4.1 of the Python tutorial中的特性, 并且所有应在运行*-import时向用户公开的模块成员都在模块的__all__变量中明确声明。

Manim在自己内部也使用者策略: 查看在调用导入初始化的文件, __init__.py (see here), 你会注意到大部分的代码都涉及从不同的子模块中导入成员,而且通常使用星号(* -imports)导入方式。

提示

如果您想为 Manim 贡献一个新的子模块,那么在导入库后,该子模块的成员只有在主 __init__.py 中列出后才能被用户访问。

这句话的意思是,在该文件中,有一个特定的导入语句位于文件开头,即 :

from ._config import *

该导入语句初始化了 Manim 的全局配置系统,该系统在整个库中的各个地方都被使用。运行该行代码后, 当前的配置选项就会被设置。这段代码负责读取您的 .cfg 文件中的选项 (所有用户至少都有一个与库一起发布的全局配置文件),并正确地处理命令行参数(如果您使用 CLI 来进行渲染)。

您可以在相应的corresponding thematic guide主题指南中了解更多有关配置系统的信息。 如果您想了解更多关于配置系统内部工作原理和初始化方式的信息,请跟随the config module’s init file配置初始化文件进行学习。

现在库已经被导入,我们可以将注意力转向下一步:读取您的场景代码(这并不特别令人兴奋, Python 只是基于我们的代码创建了一个名为 ToyExample 的新类; Manim 在这一步中几乎没有参与,唯一的例外是ToyExample继承自Scene)类)

然而,随着ToyExample 类已经创建并准备就绪,现在有一个新的重要问题需要回答:我们在construct方法中编写的代码究竟是如何执行的?

场景实例化和渲染 #

回答这个问题取决于您如何运行代码。为了使情况更清晰一些,让我们首先考虑您创建了一个名为toy_example.py的文件,其内容如下:

from manim import *

class ToyExample(Scene):
    def construct(self):
        orange_square = Square(color=ORANGE, fill_opacity=0.5)
        blue_circle = Circle(color=BLUE, fill_opacity=0.5)
        self.add(orange_square)
        self.play(ReplacementTransform(orange_square, blue_circle, run_time=3))
        small_dot = Dot()
        small_dot.add_updater(lambda mob: mob.next_to(blue_circle, DOWN))
        self.play(Create(small_dot))
        self.play(blue_circle.animate.shift(RIGHT))
        self.wait()
        self.play(FadeOut(blue_circle, small_dot))

with tempconfig({"quality": "medium_quality", "preview": True}):
    scene = ToyExample()
    scene.render()

有了这样一个文件,只需通过python toy_example.py命令运行此Python脚本即可渲染所需的场景。 然后,就像上面描述的那样,库被导入,Python已经读取并定义了ToyExample类(但请仔细阅读:尚未创建该类的任何实例)。

此时解释器即将进入 tempconfig上下文管理器。 即使您以前没有见过 Manim 的 tempconfig,它的名称已经暗示了它的功能:它创建了当前配置状态的副本, 将传递的字典中的key-value 对更改应用到副本中,并在离开上下文时恢复原始配置的版本。简而言之,它提供了一种临时设置配置选项的高级方法。

在上下文管理器内部,发生了两件事情:实例化了一个名为 ToyExample 的场景对象,并调用了其 render 方法。无论使用 Manim 的方式是什么,在底层都会执行这些操作, 即库始终会实例化场景对象, 然后调用其 render方法。 为了说明这一点,让我们简要地看一下渲染场景的两种最常见的方式:

命令行界面. 在使用命令行界面并在终端中运行manim -qm -p toy_example.py ToyExample 命令时,实际的入口点是 Manim 的__main__.py文件(here)。 Manim 使用Click 来实现命令行界面,相应的代码位于 Manim 的cli模块中 (https://github.com/ManimCommunity/manim/tree/main/manim/cli)。 创建场景类并调用其 render 方法的相应代码位于here

Jupyter notebooks. 在 Jupyter notebooks, 与库的通信由 %%manim 魔术命令处理, 该命令实现在manim.utils.ipython_magic模块中。 该魔术命令有some documentation可供参考,创建场景类并调用其 render 方法的代码位于here

既然我们知道无论哪种方式,都会创建一个 Scene对象, 让我们来探究一下 Manim 在这种情况下的具体操作。在实例化场景对象时,Manim 会执行以下操作:

scene = ToyExample()

如果我们没有实现自己的初始化方法,则会调用Scene.__init__方法。 检查相应的代码(参见here) 可以发现,Scene.__init__ 首先设置了场景对象的几个属性, 这些属性不依赖于在config中设置的任何配置选项。 然后场景会检查config.renderer的值, 并根据其值实例化一个 CairoRendererOpenGLRenderer 对象,并将其分配给其 renderer 属性。

然后场景会请求其渲染器通过调用以下方法来初始化场景:

self.renderer.init_scene(self)

检查默认的 Cairo 渲染器和 OpenGL 渲染器可以发现,init_scene方法实际上使渲染器实例化一个 SceneFileWriter 对象, 它基本上是 Manim 与 ffmpeg 的接口,并实际编写电影文件。 Cairo 渲染器(见here的实现)不需要任何进一步的初始化。 OpenGL 渲染器还执行一些额外的设置,以启用实时渲染预览窗口,这里不再详细介绍。

警告

目前,场景和其渲染器之间存在很多相互作用。这是 Manim 当前架构中的一个缺陷,我们正在努力减少这种相互依赖,以实现更简单的代码流程。

在渲染器被实例化并初始化其文件写入器之后,场景会填充更多的初始属性(值得一提的是mobjects属性,它跟踪已添加到场景中的 mobjects)。然后场景完成其实例化,并准备好被渲染。

本文的其余部分关注于我们的示例脚本中的最后一行代码:

scene.render()

这就是实际的魔法发生的地方。

检查 implementation of the render method 方法的实现会发现,有几个钩子可以用于预处理或后处理场景。毫不奇怪,Scene.render() 描述了场景的完整渲染周期。 在这个生命周期中,有三个自定义方法,它们的基本实现为空,可以根据您的需求进行重写。按照调用顺序,这些可自定义的方法如下:

  • Scene.setup(), 用于准备和设置场景的动画(例如,添加初始的 mobjects,为场景类分配自定义属性等)。

  • Scene.construct(), 是屏幕剧本的脚本,包含您的动画的编程描述。

  • Scene.tear_down(), 用于在最后一帧已经被渲染之后运行的任何操作(例如,可能会运行一些代码,根据场景中对象的状态生成自定义缩略图 - 此钩子对于在其他 Python 脚本中使用 Manim 的情况更为相关)。

动画完全渲染后,Manim调用CairoRenderer.scene_finished() 来检查是否有任何动画被播放。如果有动画被播放,Manim会告诉SceneFileWriter关闭到ffmpeg的管道,ffmpeg是一个可以将渲染帧编码为视频文件的程序。 运行这三个方法后,动画已经完全渲染,在定义并添加了动画到场景中之后, 如果没有播放任何动画,Manim会假定应输出静态图像,并调用渲染循环一次来渲染图像。

回到我们的例子中, 调用Scene.render()首先触发Scene.setup()(其中只包含pass), 然后调用Scene.construct()。 此时,我们的animation script动画脚本开始运行,从初始化开始。 在Manim中渲染场景的过程。 当调用Scene.render()时,会依次触发Scene.setup()Scene.construct()。 在这个过程中,Manim会执行动画脚本中的代码,从而定义和初始化场景中的对象和动画。 在这个特定的例子中,orange_square是一个场景中的对象,可能是一个形状或图形,它被定义并初始化为一个橙色正方形。 .

Mobject 初始化#

MMobjects是Python对象,它们代表我们想要在场景中显示的所有内容。在我们跟随调试器的步骤进入mobject初始化代码的深处之前,讨论Manim的不同类型的Mobjects和它们的基本数据结构是有意义的。

什幺是Mobject?#

Mobject Mobject代表数学对象或Manim对象(这取决于你问谁😄)。Python类Mobject是所有应该显示在屏幕上的对象的基类。 查看 of Mobjectinitialization method初始化方法,你会发现这里发生的事情并不太多:

  • 在Mobject的初始化方法中,会分配一些初始属性值,例如name(这使得渲染日志提及mobject的名称而不是它的类型)、submobjects(最初是一个空列表)、颜色和其他一些属性。 Mobject初始化方法中所进行的一些操作。 在Mobject初始化方法中,会为Mobject分配一些初始属性值,例如名称、子对象列表、颜色等。 这些属性值可以在之后的代码中被修改,以实现不同的功能和效果。 其中,name属性用于在渲染日志中标识Mobject的名称,而submobjects属性用于存储Mobject的子对象列表。 其他属性的具体作用取决于每个具体Mobject的实现。

  • 接下来,调用与points点相关的两个方法:reset_pointsgenerate_points。 这段文字描述了Mobject初始化方法中调用的两个与点相关的方法。reset_points方法用于将Mobject的点集重置为空集, 而generate_points方法用于根据Mobject的属性值生成Mobject的点集。 点集是Mobject的基本信息之一,它描述了Mobject的形状、位置和大小等属性。 在generate_points方法中,会根据具体Mobject的实现来计算Mobject的点集,以实现不同的功能和效果。

  • 最后, init_colors 被调用。

更深入地了解,您会发现 Mobject.reset_points()方法只是将 Mobject 类的 points属性设置为空的 NumPy 向量, 而另外两个方法 Mobject.generate_points()Mobject.init_colors()则只是被实现为空操作(pass)。

这很有道理:Mobject 不应该被用作实际显示在屏幕上的对象; 实际上相机(我们稍后会更详细地讨论它;它是 Cairo 渲染器中负责“拍摄”当前场景的类)不会以任何方式处理“纯粹”的 Mobjects, 它们甚至不会出现在渲染输出中。

这就是不同类型的 mobjects 起作用的地方。粗略地说,Cairo 渲染器设置了三种不同类型的 mobjects,可以进行渲染:

  • ImageMobject, 代表您可以在场景中显示的图像。

  • PMobject, 是非常特殊的 mobjects,用于表示点云;我们在本指南中不会进一步讨论它们。

  • VMobject, 这些是矢量化的 mobjects,即由通过曲线连接的点组成的 mobjects。 它们几乎无处不在,我们将在下一节中详细讨论它们。

… 什么是 VMobjects?#

正如刚才提到的,VMobjects 表示矢量化的 mobject。 要渲染 VMobject, 相机会查看 VMobjectpoints 属性, 并将其分成每组四个点。 然后,每个组都用于构建一个立方贝塞尔曲线,其中第一个和最后一个点描述曲线的端点(“锚点”), 第二个和第三个点描述其之间的控制点(“手柄”)

提示

要了解更多关于Bézier curves(贝塞尔曲线)的知识, 可以查看 Pomax 编写的在线教程 《A Primer on Bézier curves》。 在第一章节中有一个代表立方贝塞尔曲线的in §1,红色和黄色的点是“锚点”,绿色和蓝色的点是“手柄”。

Mobject 不同, VMobject可以在屏幕上显示(即使在技术上,它仍被认为是一个基类)。 为了说明如何处理点,考虑以下简短的示例,其中包含 8 个点的 VMobject (因此由 8/4 = 2 个立方贝塞尔曲线组成)。绘制的 VMobject 为绿色。手柄绘制为红色点,与其最近的锚点之间有一条线。

Example: VMobjectDemo

../_images/VMobjectDemo-1.png
from manim import *

class VMobjectDemo(Scene):
    def construct(self):
        plane = NumberPlane()
        my_vmobject = VMobject(color=GREEN)
        my_vmobject.points = [
            np.array([-2, -1, 0]),  # start of first curve
            np.array([-3, 1, 0]),
            np.array([0, 3, 0]),
            np.array([1, 3, 0]),  # end of first curve
            np.array([1, 3, 0]),  # start of second curve
            np.array([0, 1, 0]),
            np.array([4, 3, 0]),
            np.array([4, -2, 0]),  # end of second curve
        ]
        handles = [
            Dot(point, color=RED) for point in
            [[-3, 1, 0], [0, 3, 0], [0, 1, 0], [4, 3, 0]]
        ]
        handle_lines = [
            Line(
                my_vmobject.points[ind],
                my_vmobject.points[ind+1],
                color=RED,
                stroke_width=2
            ) for ind in range(0, len(my_vmobject.points), 2)
        ]
        self.add(plane, *handles, *handle_lines, my_vmobject)

警告

通常不建议手动设置VMobject的点; 有专门的方法可以为您处理这些点 - 但是在实现自己的自定义 VMobject 时可能会很重要。

正方形和圆形:回到我们的玩具例子#

通过对不同类型的 mobject 有基本的理解,以及对矢量化 mobject 如何构建的概念,我们现在可以回到我们的示例和Scene.construct()方法的执行中。 在动画脚本的前两行中,初始化了orange_squareblue_circle

创建橙色正方形并运行

Square(color=ORANGE, fill_opacity=0.5)

当调用Square 的初始化方法Square.__init__时, 看看这个实现,我们可以看到正方形的side_length属性被设置,然后

super().__init__(height=side_length, width=side_length, **kwargs)

被调用。 接下来执行的是super函数调用,这是Python中调用父类初始化函数的方式。 由于Square继承自Rectangle, 因此下一个被调用的方法是Rectangle.__init__。在这里,只有前三行对我们真正有用:

super().__init__(UR, UL, DL, DR, color=color, **kwargs)
self.stretch_to_fit_width(width)
self.stretch_to_fit_height(height)

首先,调用Rectangle的父类 – Polygon的初始化函数。 传递的四个位置参数是多边形的四个角:UR代表右上角(等于UP + RIGHT), UL代表左上角(等于UP + LEFT),以此类推。 在我们继续深入调试器之前,让我们观察一下构建的多边形会发生什么: 剩下的两行代码将多边形拉伸以适应指定的宽度和高度,从而创建具有所需尺寸的矩形。

Polygon的初始化函数特别简单, 它只调用其父类Polygram 的初始化函数。在那里,我们几乎到达了链的末端: Polygram继承自 VMobject, 其初始化函数主要设置了一些属性的值(与Mobject.__init__非常相似, 但更具体地针对组成mobject的贝塞尔曲线)。

在调用VMobject的初始化函数后,Polygram的构造函数还做了一件有点奇怪的事情: 它设置了点(你可能还记得上面提到过,这些点实际上应该在Polygram的相应generate_points方法中设置)。

Warning

在几个实例中,Mobject的实现并没有完全遵循Manim的接口规范。这是不幸的,我们正在积极努力提高一致性。欢迎大家提供帮助!

不详细展开,Polygram通过VMobject.start_new_path()VMobject.add_points_as_corners()设置其points属性,这些方法会适当地设置锚点和手柄的四元组。在设置了这些点之后,Python继续处理调用堆栈,直到它到达最初调用的方法: Square的初始化方法。 在此之后,正方形被初始化并赋值给orange_square变量。

blue_circle的初始化与orange_square的初始化类似,主要区别在于Circle的继承链不同。 让我们简要地跟随调试器的追踪:

Circle.__init__()的实现立即调用Arc的初始化方法,因为在Manim中,圆形只是一个\(\tau = 2\pi\)的弧。 在初始化弧时,设置了一些基本属性(如Arc.radiusArc.arc_centerArc.start_angleArc.angle), 然后调用了其父类TipableVMobject的初始化方法(它是一个相当抽象的基类,用于连接箭头尖端的mobject)。 请注意,与Polygram不同,这个类不会预先生成圆的点。

之后,事情就不那么激动人心了:TipableVMobject 再次设置了一些与添加箭头尖端相关的属性,然后传递到 VMobject的初始化方法。 从那里开始,Mobject被初始化并调用Mobject.generate_points(), 它实际上运行了在Arc.generate_points()中实现的方法。

在我们的 orange_squareblue_circle 都被初始化之后, 正方形实际上被添加到了场景中。Scene.add()方法实际上执行了一些有趣的操作, 因此值得在下一节中深入挖掘。

将 Mobjects 加到 Scene 中#

下面是我们construct方法中运行的代码:

self.add(orange_square)

从高层次的角度来看,Scene.add()orange_square添加到应该被渲染的mobjects列表中, 该列表存储在场景的mobjects属性中。 然而,它以非常谨慎的方式这样做,以避免将mobject添加到场景中超过一次的情况。 乍一看,这听起来像是一个简单的任务, 但问题是Scene.mobjects不是一个“扁平”的mobjects列表,而是一个可能包含mobjects本身等的mobjects列表。

通过对Scene.add()中的代码进行逐步调试, 我们可以看到首先检查当前是否使用OpenGL渲染器(我们没有使用)——将mobjects添加到场景的工作对于OpenGL渲染器略有不同(实际上更容易!)。然后,进入Cairo渲染器的代码分支,并将所谓的前景mobjects列表(它们在所有其他mobjects之上呈现)添加到传递mobjects的列表中。 这是为了确保前景mobjects将保持在其他mobjects之上,即使添加了新的mobjects。在我们的情况下,前景mobjects列表实际上为空,因此什么也不会改变。

接下来,Scene.restructure_mobjects()被调用, 传递要添加的mobjects列表作为要 to_remove 的参数,这可能一开始听起来有点奇怪。 实际上,这确保了mobjects不会被添加两次, 如上所述:如果它们之前存在于场景Scene.mobjects列表中(即使它们是作为其他mobject的子项包含的), 则首先从列表中删除它们。Scene.restrucutre_mobjects()的工作方式相当“激进”:它总是在给定的mobjects列表上操作; 在add方法中出现了两个不同的列表: 默认列表Scene.mobjects (没有传递额外的关键字参数) 和Scene.moving_mobjects (稍后我们将更详细地讨论它们)。它遍历列表中的所有成员,并检查任何一个传递给to_remove的mobject是否作为子项(在任何嵌套级别)包含在其中。如果是这样,它们的父mobject将被解构,它们的兄弟将直接插入到更高的一级中。考虑以下示例:

>>> from manim import Scene, Square, Circle, Group
>>> test_scene = Scene()
>>> mob1 = Square()
>>> mob2 = Circle()
>>> mob_group = Group(mob1, mob2)
>>> test_scene.add(mob_group)
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Group]
>>> test_scene.restructure_mobjects(to_remove=[mob1])
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Circle]

请注意,该组被解散,圆形移动到test_scene.mobjects的根层中。

在“重构”mobject列表之后,要添加的mobject被简单地附加到Scene.mobjects中。在我们的玩具示例中,Scene.mobjects列表实际上为空, 因此restructure_mobjects方法实际上不会执行任何操作。 orange_square只是添加到Scene.mobjects中, 并且在此时Scene.moving_mobjects 列表也仍然为空, 因此什么也不会发生,Scene.add()方法返回。

我们在讨论渲染循环时将更多地涉及到moving_mobject 列表。 在此之前,让我们看一下我们玩具示例中的下一行代码,其中包括初始化一个动画类。

ReplacementTransform(orange_square, blue_circle, run_time=3)

因此,是时候谈谈 Animation.

Animations(动画) 和 Render(渲染) 循环#

初始化 animations(动画)#

在我们跟随调试器的跟踪之前,让我们简要讨论一下(抽象的)基类Animation的一般结构。 一个动画对象包含了生成相应帧所需的所有信息。在Manim中,动画(指动画对象)总是与特定的mobject相关联; 即使是AnimationGroup(实际上应该将其视为对一组mobject的动画,而不是一组动画)也是如此。 此外,除了特定的特殊情况外,动画的运行时间也是固定并预先知道的。 这意味着渲染器知道每个动画需要生成多少帧,并可以利用这些信息来优化渲染过程。

实际上,动画的初始化并不是很令人兴奋,Animation.__init__()只是设置一些从关键字参数传递而来的属性, 并确保Animation.starting_mobjectAnimation.mobject属性被填充。 一旦动画开始播放,starting_mobject属性将保存动画所附加的mobject的未修改副本; 在初始化期间,它被设置为占位符mobject。mobject属性被设置为动画所附加的mobject。

动画有几个在渲染循环期间调用的特殊方法:

  • Animation.begin(), 是在每个动画开始之前调用的一个特殊方法,它的名字也暗示了这一点。在这个方法中,动画的所有必要设置都会发生。也就是说,在第一帧渲染之前会调用它。

  • Animation.finish() 是与begin方法对应的方法,在动画的生命周期结束时调用(即在最后一帧渲染之后)。 它完成一些清理工作和资源回收,以确保动画在播放完毕后能够正确地结束。

  • Animation.interpolate() 它将附加到动画的mobject更新为相应的动画完成百分比。例如,在渲染循环中,如果调用了some_animation.interpolate(0.5), 则附加的mobject将被更新为完成50%动画的状态。 这个方法的作用是实现动画效果的平滑过渡,使得在渲染过程中每一帧的状态都能够正确反映动画当前的完成度。

当我们进入实际的渲染循环时,我们将讨论有关这些动画方法的详细信息。 现在,我们继续我们的玩具示例,并查看在初始化ReplacementTransform动画时运行的代码。

ReplacementTransform 的初始化方法只包括对其父类Transform的构造函数的调用, 并附加了一个关键字参数replace_mobject_with_target_in_scene 设置为TrueTransform 随后设置控制起始mobject的点如何变形为目标mobject的属性,然后传递给Animation的初始化方法。 在Animation的初始化方法中,还会处理动画的其他基本属性,例如它的运行时间(run_time)、rate_func,等等。 然后,动画对象就被完全初始化并准备好播放了。

play 调用: 准备进入 Manim 的渲染循环#

我们终于到了渲染循环了。让我们来看一下当调用Scene.play() 时运行的代码。

提示

请注意,本文特别讨论Cairo渲染器。到目前为止, OpenGL渲染器的情况与之类似;虽然一些基本mobject可能不同, 但mobject的控制流和生命周期仍然基本相同。但是,在渲染循环方面存在更实质性的差异。

当你检查play方法时会发现,Scene.play() 几乎立即转移到渲染器的play 方法,对于我们的场景,这是CairoRenderer.playScene.play() 处理的唯一事情是管理你可能传递给它的子标题(详见Scene.play()Scene.add_subcaption() 的文档)。

警告

正如之前所说,此时场景和渲染器之间的通信并不是非常清晰,因此如果您不运行调试器并自己逐步执行代码,下面的段落可能会令人困惑。

CairoRenderer.play()方法内部,渲染器首先检查是否可以跳过当前的播放调用。 这可能发生在几种情况下,例如当在CLI中传递了-s 参数(即只渲染最后一帧), 或者当传递了-n标志并且当前的播放调用在指定的渲染范围之外时。 "跳过状态"会通过调用CairoRenderer.update_skipping_status()来更新。

接下来,渲染器会要求场景处理播放调用中的动画,以便渲染器获取所有所需的信息。 更具体地说,会调用Scene.compile_animation_data(),然后这个方法会处理以下几件事情:

  • 该方法会处理所有的动画和传递给Scene.play() 调用的关键字参数。特别地,这意味着它会确保传递给play 调用的所有参数实际上都是动画(或者是.animate 语法调用,在这个时候也会组装成实际的 Animation 对象)。它还会将传递给Scene.play 的任何与动画相关的关键字参数(例如run_timerate_func )传递给每个单独的动画。 处理后的动画然后存储在场景的animations 属性中(稍后由渲染器读取...)。

  • 该方法将所有播放的动画绑定的mobject 添加到场景中(前提是该动画不是引入mobject 的动画 - 对于这些动画,将稍后添加到场景中)。

  • 如果播放的动画是Wait动画(即在Scene.wait()调用中),该方法会检查是否应该渲染静态图像, 或者是否应该像通常一样处理渲染循环(详见Scene.should_update_mobjects(),其中包括检查是否有任何依赖时间的更新函数等等)。

  • 最后,该方法确定播放调用的总运行时间(此时计算为传递的动画运行时间的最大值)。这个运行时间被存储在场景的duration 属性中。

在场景编译动画数据之后,渲染器继续准备进入渲染循环。现在,它检查之前确定的跳过状态。 如果渲染器可以跳过此播放调用,它将设置当前播放调用的哈希值(稍后我们将回到此处),并将渲染器的时间增加确定的动画运行时间。

否则,渲染器会检查是否应该使用Manim的缓存系统。缓存系统的思想很简单:对于每个播放调用, 计算一个哈希值,然后将其存储起来。在重新渲染场景时,再次生成哈希并与存储的值进行比较。如果相同,则重用缓存输出,否则重新完全重新渲染。我们在这里不会详细介绍缓存系统;如果您想了解更多信息, utils.hashing 模块中的get_hash_from_play_call() 函数实际上是缓存机制的入口点。

如果必须渲染动画,则渲染器会要求其SceneFileWriter 启动写入过程。 这个过程是通过调用ffmpeg 启动的,并打开了一个管道,可以将渲染的原始帧写入。 只要管道是打开的,就可以通过文件写入器的writing_process 属性访问该进程。在写入过程准备好后,渲染器会要求场景“开始”动画。

首先,它通过调用它们的设置方法(Animation._setup_scene(), Animation.begin()) 实际开始所有动画。这样做时,由动画(例如通过Create等) 引入的新mobject将被添加到场景中。 此外,动画会暂停在其mobject上调用的更新器函数,并将其mobject设置为对应于动画的第一帧的状态。

在当前play调用中的所有动画都完成上述操作之后,Cairo 渲染器将确定场景中哪些mobject可以静态地绘制到背景中,哪些必须在每帧中重新绘制。它通过调用 Scene.get_moving_and_static_mobjects(), 来实现这一点,将结果划分为相应的moving_mobjectsstatic_mobjects属性中。

注意

确定静态和运动的mobject的机制是针对Cairo渲染器的, OpenGL渲染器的工作方式不同。 基本上,运动的mobject是通过检查它们、 它们的任何子节点或任何"在它们下面"的mobject (按照场景中处理mobject的顺序)是否具有附加的更新函数, 或者它们是否出现在当前的任何动画中来确定的。有关详细信息, 请参见Scene.get_moving_mobjects()的实现。

到目前为止,我们实际上还没有从场景中渲染任何(部分)图像或电影文件。 然而,这将要改变。在我们进入渲染循环之前, 让我们简要回顾一下我们的玩具示例, 并讨论一下那里通用的Scene.play() 调用设置是什么样子的。

对于播放ReplacementTransform 的调用,没有需要处理的字幕。 然后,渲染器要求场景编译动画数据: 传递的参数已经是一个动画(不需要额外的准备工作), 也不需要处理任何关键字参数(因为我们没有指定任何额外的参数来play)。 绑定到动画的mobject(orange_square)已经是场景的一部分(因此不需要任何操作)。 最后,运行时间(3秒钟)被提取并存储在Scene.duration中。 然后,渲染器检查是否应该跳过(不应该),然后检查动画是否已经被缓存(没有)。 相应的动画哈希值被确定并传递给文件写入器,后者随后调用ffmpeg 来启动等待来自库的渲染帧的写入过程。

然后场景begin动画: 对于ReplacementTransform, 这意味着动画填充其所有相关的动画属性(即, 起始和目标mobject的兼容副本, 以便可以安全地在两者之间进行插值)。

确定静态和运动的mobject 的机制考虑到场景中的所有mobject 此时只有orange_square), 并确定orange_square绑定到当前正在播放的动画。 因此,该正方形被归类为"运动的mobject"。

是时候渲染一些帧了。

渲染循环(这次是真正的)#

如上所述,由于在场景中确定静态和运动的mobject的机制,渲染器知道哪些mobject可以静态绘制到场景的背景中。实际上,这意味着它部分地渲染场景(以产生背景图像),然后在迭代动画的时间进度时,只有"运动的mobject"被重新绘制在静态背景的上方。

渲染器调用CairoRenderer.save_static_frame_data(), 首先检查当前是否有任何静态mobject, 如果有,则更新帧(仅使用静态mobject;稍后详细介绍如何实现), 然后将表示渲染帧的NumPy数组保存在static_image属性中。 在我们的玩具示例中,没有静态mobject, 因此static_image属性简单地设置为None

接下来,渲染器询问场景当前的动画是否是"冻结帧"动画,这意味着在动画时间进度的每一帧中,渲染器实际上不需要重新绘制运动的mobject。它可以只取最新的静态帧,并在整个动画中显示它。

Note

如果只播放静态的Wait 动画,动画被认为是"冻结帧"动画。 有关详细信息,请参见上面的Scene.compile_animation_data()的描述, 或者请参见Scene.should_update_mobjects()的实现。

如果不是这种情况(就像我们的玩具例子一样), 渲染器接下来调用Scene.play_internal()方法, 这是渲染循环的重要部分(在该循环中,库会遍历动画的时间进度并渲染相应的帧)。

Scene.play_internal(), 中,执行以下步骤:

  • 场景通过调用Scene.get_run_time() 方法确定动画的运行时间。该方法基本上获取Scene.play() 调用中所有动画的最大run_time 属性。

  • 然后,时间进度是通过(内部的)Scene._get_animation_time_progression()方法构造的, 该方法包装了实际的Scene.get_time_progression()方法。 时间进度是一个tqdm 进度条对象, 用于迭代np.arange(0, run_time, 1 / config.frame_rate)。 换句话说,time progression时间进度保存了时间戳(相对于当前动画,因此从0开始, 以总动画运行时间结束, 步长由渲染帧率确定)的时间线,在该时间线上应该渲染新的动画帧。

  • 然后,场景在时间进度上进行迭代:对于每个时间戳t,调用Scene.update_to_time()方法,它的作用是...

    • ...首先计算自上次更新以来经过的时间(可能为0,特别是在初始调用时),并将其称为dt

    • 然后(按照传递给Scene.play()的动画的顺序), 调用Animation.update_mobjects()来触发附加到相应动画的所有更新器函数, 但不包括动画的"主要mobject"(例如,对于Transform, 是起始和目标mobject的未修改副本——有关更多详细信息, 请参见Animation.get_all_mobjects_to_update()),

    • 然后,相对于当前动画计算相对时间进度(alpha = t / animation.run_time), 然后使用调用Animation.interpolate()更新动画状态。

    • 在处理所有传递的动画之后,运行场景中所有mobject、所有网格和最后附加到场景本身的更新器函数。

此时,所有mobject的内部(Python)状态已更新以匹配当前处理的时间戳。如果不应该跳过渲染,则现在是拍照的时候了!

注意

一旦进入 Scene.play_internal(), 内部状态的更新(在时间进度上的迭代)就会发生。 这确保即使帧不需要被渲染(例如,因为传递了-n CLI标志、某些内容已被缓存,或者因为我们可能在跳过渲染的部分), 更新器函数仍然可以正确运行,并且第一个被渲染的帧的状态保持一致。

为了渲染图像,场景调用其渲染器的相应方法CairoRenderer.render(), 并仅传递运动的moving mobjects列表 (请记住,假定static 静态 mobjectsmobject已经被静态地绘制到场景的背景中)。 当渲染器通过调用CairoRenderer.update_frame() 更新其当前帧时,所有的艰苦工作都发生了。

首先,渲染器通过检查是否已经存储了不同于Nonestatic_image来准备其Camera相机。 如果是这样,它通过Camera.set_frame_to_background()将图像设置为相机的background image, 否则它只是通过Camera.reset()重置相机。 然后,通过调用Camera.capture_mobjects()要求相机捕获场景。

这里的事情有点技术性, 而且在某个时候深入到实现中会更有效, 但是这里是一份摘要,介绍一下相机被要求捕获场景后会发生什么:

  • 首先,创建一个mobjects的扁平列表(因此,子mobjects会从其父级中提取出来)。然后,按照相同类型的mobjects对此列表进行分组处理(例如,一批矢量化的mobjects,接着是一批图像mobjects,接着是更多的矢量化的mobjects等——在许多情况下,只会有一批矢量化的mobjects)。

  • 根据当前处理的批次的类型,相机使用专用的display functions显示函数Mobject 对象转换为存储在相机的pixel_array 属性中的NumPy 数组。在这个上下文中最重要的例子是矢量化mobjects 的显示函数Camera.display_multiple_vectorized_mobjects() ,或者更具体地说(如果您没有将背景图像添加到您的VMobject 中), Camera.display_multiple_non_background_colored_vmobjects() 。该方法首先获取当前的Cairo上下文,然后针对批次中的每个VMobject ,调用Camera.display_vectorized() 。在那里,mobject的实际背景描边、填充,然后描边被绘制到上下文中。有关更多详细信息,请参见Camera.apply_stroke()Camera.set_cairo_context_color() ——但在后者方法中, 根据mobject 的点确定的实际Bézier曲线被绘制;这是与Cairo的低级交互发生的地方。

处理完所有批次后,相机在其pixel_array 属性中以NumPy数组的形式具有场景在当前时间戳的图像表示。 然后,渲染器获取此数组并将其传递给其SceneFileWriter。 这结束了渲染循环的一个迭代,在处理完时间进度之后,Scene.play_internal() 调用完成之前会进行一些最终的清理工作。

在我们的玩具示例的上下文中,渲染循环的 TL;DR 如下所示:

  • 场景发现应该播放一个3秒长的动画(ReplacementTransform 将橙色正方形变为蓝色圆形)。根据请求的中等渲染质量, 帧率为每秒30帧,因此创建了具有步长[0, 1/30, 2/30, ..., 89/30]的时间进度。

  • 在内部渲染循环中,处理每个这样的时间戳: 没有更新器函数,所以实际上场景将转换动画的状态更新到所需的时间戳上 (例如,在时间戳t = 45/30时, 动画完成了alpha = 0.5的速率)。

  • 然后,场景要求渲染器执行其工作。渲染器要求其相机捕获场景,在这一点上需要处理的唯一mobject是附加到变换的主要mobject;相机将mobject的当前状态转换为NumPy数组中的条目。渲染器将此数组传递给文件写入器。

  • 在循环结束时,已将90个帧传递给文件写入器。

完成渲染循环 #

Scene.play_internal() 调用的最后几个步骤不太令人激动:对于每个动画,都会调用相应的Animation.finish()Animation.clean_up_from_scene() 方法。

注意

请注意,作为Animation.finish() 的一部分,将使用参数1.0调用Animation.interpolate() 方法——您可能已经注意到,动画的最后一帧有时可能会有些偏差或不完整。 这是当前的设计!在渲染循环中呈现的最后一帧 (在渲染的视频中以1 / frame_rate 秒的持续时间显示)对应于动画结束前1 / frame_rate 秒的状态。为了在视频中显示最后一帧, 我们需要在视频末尾添加另外1 / frame_rate 秒——这意味着渲染为1秒的Manim视频将略长于1秒。我们曾经决定反对这种做法。

最后,在终端中关闭时间进度条来结束时间进度。随着时间进度的关闭,Scene.play_internal() 调用完成,我们回到渲染器,现在它会命令SceneFileWriter 关闭为该动画打开的电影管道:一个部分电影文件被写入。

这基本上结束了Scene.play 调用的演示,实际上对于我们的玩具例子,也没有太多要说的了: 此时,一个代表ReplacementTransform 播放的部分电影文件已经被写入。Dot 的初始化类似于上面讨论的blue_circle 的初始化。Mobject.add_updater() 调用仅将函数附加到 small_dotupdaters 属性上。剩下的Scene.play()Scene.wait() 调用遵循上面讨论的渲染循环部分的完全相同的过程;每次此类调用都会产生一个相应的部分电影文件。

一旦Scene.construct() 方法已经完全处理(因此所有相应的部分电影文件都已经被写入),场景调用其清理方法Scene.tear_down() ,然后要求其渲染器完成场景。渲染器又会要求其场景文件写入器通过调用SceneFileWriter.finish() 来结束,这将触发将部分电影文件组合成最终成品。

是的!这就是Manim在幕后运作的更或多或少详细的描述。虽然我们没有在这个演示中详细讨论每一行代码,但它应该给你一个相当好的想法,即库的一般结构设计和至少Cairo渲染流程的外观。