深入探讨Manim的内部机制

作者: Benjamin Hackl

免责声明

本指南反映了库在版本v0.16.0时的状态,主要涉及Cairo渲染器。最新版本的Manim情况可能有所不同;如果有重大差异,我们将在下面添加注释。

介绍

Manim 可以是一个很棒的库,如果它的行为符合你的期望和/或你预期的方式。不幸的是,情况并非总是如此(如果你已经自己尝试过一些动画,你可能已经知道了)。要理解问题出在哪里,有时唯一的选择是深入研究库的源代码——但为了做到这一点,你需要知道从哪里开始挖掘。

本文旨在作为渲染过程中的某种生命线。 我们的目标是提供适当的细节,描述当 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(你猜这个点第一次添加到场景时位于哪里?回答这个问题已经需要详细的渲染过程知识。)。这个点附加了一个更新器, 当圆形向右移动时,点也随之移动。最后,所有的mobjects都会淡出。

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

对于这个例子,输出(幸运地)与我们的预期一致。

概述

因为本文包含大量信息,这里简要概述了以下章节的内容,从非常高的层次进行讨论。

  • Preliminaries: 在本章中,我们将详细解析为渲染场景所做的所有准备工作;直到用户重写的construct方法运行的那一刻。这包括简要讨论使用Manim的命令行界面(CLI)与其他渲染方式(例如,通过Jupyter笔记本,或在你的Python脚本中自行调用Scene.render()方法)的对比。

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

  • 动画和渲染循环: And finally, in the last chapter we walk through the instantiation of Animation objects (the blueprints that hold information on how Mobjects should be modified when the render loop runs), followed by a investigation of the infamous Scene.play() call. We will see that there are three relevant parts in a Scene.play() call; a part in which the passed animations and keyword arguments are processed and prepared, followed by the actual “render loop” in which the library steps through a time line and renders frame by frame. The final part does some post-processing to save a short video segment (“partial movie file”) and cleanup for the next call to Scene.play(). In the end, after all of Scene.construct() has been run, the library combines the partial movie files to one video.

就这样,让我们直接进入正题

预备知识

导入库

无论你如何确切地告诉系统渲染场景,即无论你是运行manim -qm -p file_name.py ToyExample,还是通过类似以下的Python脚本片段直接渲染场景

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

或者你是否在Jupyter笔记本中渲染代码,你仍然在告诉你的python解释器导入库。通常用于执行此操作的模式是

from manim import *

(尽管这在总体上是一个有争议的策略)它导入了许多随库一起提供的类和函数,并使它们在您的全局命名空间中可用。我明确避免声明它导入了库的所有类和函数,因为它并没有这样做:Manim利用了Python教程第6.4.1节中描述的做法,所有在运行*-导入时应向用户公开的模块成员都在模块的__all__变量中明确声明。

Manim 内部也使用这种策略:看一下调用导入时运行的文件,__init__.py(参见 这里), 你会注意到该模块中的大部分代码都涉及从各种不同的子模块导入成员,再次使用 *-导入。

提示

如果你想为Manim贡献一个新的子模块,主__init__.py文件是必须列出它的地方,以便在导入库后用户可以访问其成员。

在那个文件中,文件开头有一个特别的导入,即:

from ._config import *

这将初始化Manim的全局配置系统,该系统在库的各个地方使用。库运行此行后,当前的配置选项将被设置。其中的代码负责读取您的.cfg文件中的选项(所有用户至少有一个随库一起提供的全局文件),并正确处理命令行参数(如果您使用CLI进行渲染)。

你可以阅读更多关于配置系统的内容在 相应的主题指南中,如果你对学习 更多关于配置系统的内部结构以及它是如何初始化的感兴趣, 请跟随代码流程从配置模块的初始化文件开始。

现在库已经导入,我们可以将注意力转向下一步: 阅读你的场景代码(这并不是特别令人兴奋,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,它的名字已经暗示了它的功能:它创建当前配置状态的副本,应用传递的字典中的键值对更改,并在离开上下文时恢复配置的原始版本。简而言之:它提供了一种临时设置配置选项的巧妙方式。

在上下文管理器中,发生了两件事:一个实际的ToyExample场景对象被实例化,并且调用了render方法。使用Manim的每种方式最终都会沿着这些线路做一些事情,库总是实例化场景对象,然后调用其render方法。为了说明这确实是情况,让我们简要地看一下渲染场景的两种最常见方式:

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

Jupyter 笔记本。 在 Jupyter 笔记本中,与库的通信由 %%manim 魔术命令处理,该命令在 manim.utils.ipython_magic 模块中实现。有关魔术命令的一些 文档可用,创建场景类并调用其渲染方法的代码位于此处

既然我们知道无论哪种方式,都会创建一个场景对象,那么让我们来研究一下当这种情况发生时,Manim会做什么。当实例化我们的场景对象时

scene = ToyExample()

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

然后场景通过调用其渲染器来初始化场景

self.renderer.init_scene(self)

检查默认的Cairo渲染器和OpenGL渲染器显示,init_scene方法有效地使渲染器实例化了一个SceneFileWriter对象,该对象基本上是Manim与ffmpeg的接口,并实际写入电影文件。Cairo渲染器(参见实现这里)不需要任何进一步的初始化。OpenGL渲染器进行了一些额外的设置以启用实时渲染预览窗口,我们在此不进一步详细说明。

警告

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

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

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

scene.render()

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

检查渲染方法的实现揭示了有几个钩子可以用于场景的预处理或后处理。不出所料,Scene.render()描述了一个场景的完整渲染周期。在这个生命周期中,有三个自定义方法,它们的基本实现是空的,可以被重写以适应你的目的。按照它们被调用的顺序,这些可定制的方法是:

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

  • Scene.construct(),这是你的屏幕剧本的脚本,包含你的动画的程序化描述,以及

  • Scene.tear_down(),用于在最后一帧渲染完成后,您可能希望在场景上运行的任何操作(例如,这可以运行一些代码,根据场景中对象的状态生成视频的自定义缩略图——这个钩子更适用于Manim在其他Python脚本中使用的情况)。

在这三种方法运行之后,动画已经完全渲染,Manim调用CairoRenderer.scene_finished()来优雅地完成渲染过程。这会检查是否有任何动画已经播放——如果有,它会告诉SceneFileWriter关闭到ffmpeg的管道。如果没有,Manim会假设应该输出静态图像,然后通过调用渲染循环(见下文)一次来使用相同的策略进行渲染。

回到我们的玩具示例中, 调用 Scene.render() 首先 触发 Scene.setup()(它只包含 pass),随后是 对 Scene.construct() 的调用。此时,我们的动画脚本 开始运行,首先是 orange_square 的初始化。

Mobject 初始化

简而言之,Mobjects 是代表我们想要在场景中显示的所有事物的 Python 对象。在我们跟随调试器深入 mobject 初始化代码之前,讨论 Manim 的不同类型的 Mobjects 及其基本数据结构是有意义的。

Mobject 到底是什么?

Mobject 代表 数学对象Manim 对象 (取决于你问谁 😄)。Python 类 Mobject 是 所有应该显示在屏幕上的对象的基类。 查看 初始化方法Mobject,你会发现里面并没有太多内容:

  • 一些初始属性值被分配,比如 name(这使得渲染日志提到mobject的名称而不是其类型),submobjects(最初是一个空列表),color,以及其他一些。

  • 然后,调用了两个与相关的方法:reset_points,接着是generate_points

  • 最后,调用了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 表示向量化的 mobjects。为了渲染一个 VMobject,相机会查看 VMobjectpoints 属性,并将其分成每组四个点的集合。然后,每组点用于构建一个三次贝塞尔曲线,其中第一个和最后一个条目描述曲线的端点(“锚点”),第二个和第三个条目描述中间的控制点(“手柄”)。

提示

要了解更多关于贝塞尔曲线的信息,请查看Pomax编写的优秀在线教科书贝塞尔曲线入门——其中有一个展示三次贝塞尔曲线的游乐场在§1,红色和黄色的点是“锚点”,绿色和蓝色的点是“手柄”。

Mobject相比,VMobject可以在屏幕上显示(尽管从技术上讲,它仍然被视为一个基类)。为了说明点是如何处理的,考虑以下一个包含8个点的VMobject的简短示例(因此由8/4 = 2个三次贝塞尔曲线组成)。生成的VMobject以绿色绘制。手柄绘制为红点,并有一条线连接到它们最近的锚点。

示例: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)
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时,这可能是有意义的。

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

在对不同类型的mobjects有了基本了解,并且对向量化mobjects的构建有了概念之后,我们现在可以回到我们的玩具示例以及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的Bézier曲线)。

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

警告

在某些情况下,mobjects 的实现并没有完全遵循 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 的初始化方法(这是一个相当抽象的基类,用于可以附加箭头尖端的 mobjects)。请注意,与 Polygram 不同,这个类不会预先生成圆的点。

之后,事情就不那么令人兴奋了:TipableVMobject 再次设置了一些与添加箭头提示相关的属性,然后传递给 VMobject 的初始化方法。从那里,Mobject 被初始化,并且调用了 Mobject.generate_points(),这实际上运行了在 Arc.generate_points() 中实现的方法。

在我们的orange_squareblue_circle都被初始化之后, 方块实际上被添加到了场景中。Scene.add()方法 实际上做了一些有趣的事情,因此在下一节中值得深入探讨。

向场景中添加Mobjects

接下来运行的construct方法中的代码是

self.add(orange_square)

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

逐步查看Scene.add()中的代码,我们首先检查是否正在使用OpenGL渲染器(我们并没有使用)——对于OpenGL渲染器,向场景中添加mobjects的工作方式略有不同(实际上更简单!)。然后,进入Cairo渲染器的代码分支,并将所谓的前景mobjects(这些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 中的任何 mobjects 作为子对象(在任何嵌套级别)包含在其中。如果是这样,它们的父 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了。

动画和渲染循环

初始化动画

在我们跟随调试器的踪迹之前,让我们简要讨论一下(抽象)基类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 设置为 True。然后,Transform 设置控制起始 mobject 的点如何变形为目标 mobject 的点的属性,并传递给 Animation 的初始化方法。动画的其他基本属性(如其 run_timerate_func 等)在那里处理——然后动画对象完全初始化并准备好播放。

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

我们终于到了,渲染循环就在我们的掌握之中。让我们来看看当调用Scene.play()时运行的代码。

提示

回想一下,本文专门讨论Cairo渲染器。 到目前为止,对于OpenGL渲染器来说,情况大致相同;虽然一些基础mobjects可能不同,但mobjects的控制流程和生命周期仍然大致相同。在渲染循环方面存在更显著的差异。

正如你在检查方法时会看到的,Scene.play()几乎立即传递给了渲染器的play方法,在我们的例子中是CairoRenderer.playScene.play()负责处理的是你可能传递给它的子字幕的管理(更多信息请参见Scene.play()Scene.add_subcaption()的文档)。

警告

如前所述,场景和渲染器之间的通信目前并不处于非常清晰的状态,因此如果你不运行调试器并自己逐步查看代码,以下段落可能会让人感到困惑。

CairoRenderer.play()内部,渲染器首先检查是否可以跳过当前播放调用的渲染。例如,当-s被传递给CLI时(即只应渲染最后一帧),或者当-n标志被传递且当前播放调用在指定的渲染边界之外时,可能会发生这种情况。“跳过状态”通过调用CairoRenderer.update_skipping_status()来更新。

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

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

  • 它将所有播放动画所绑定的mobjects添加到场景中(前提是动画不是引入mobject的动画——对于这些动画,添加到场景的操作会在稍后进行)。

  • 如果播放的动画是等待动画(这种情况发生在Scene.wait()调用中),该方法会检查是否应该渲染静态图像,或者是否应该像往常一样处理渲染循环(有关确切条件,请参见Scene.should_update_mobjects(),基本上它会检查是否有任何依赖于时间的更新函数等)。

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

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

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

在需要渲染动画的情况下,渲染器会要求其SceneFileWriter启动一个写入过程。这个过程通过调用ffmpeg开始,并打开一个管道,可以将渲染的原始帧写入其中。只要管道保持打开状态,就可以通过文件写入器的writing_process属性访问该过程。有了写入过程后,渲染器会要求场景“开始”动画。

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

在当前play调用中的所有动画发生后, Cairo渲染器确定场景中的哪些mobjects可以静态绘制到背景中, 哪些需要每帧重新绘制。它通过调用 Scene.get_moving_and_static_mobjects()来实现这一点, 并且mobjects的最终分区存储在相应的moving_mobjectsstatic_mobjects属性中。

注意

确定静态和移动mobjects的机制是特定于Cairo渲染器的,OpenGL渲染器的工作方式不同。基本上,移动mobjects是通过检查它们、它们的任何子对象,或者它们“下方”的任何mobjects(在场景中处理mobjects的顺序意义上)是否附加了更新函数,或者它们是否出现在当前动画之一中来确定的。有关更多详细信息,请参见Scene.get_moving_mobjects()的实现。

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

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

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

确定静态和移动物体的机制考虑了所有场景中的物体(此时只有orange_square),并确定orange_square绑定到当前正在播放的动画。因此,该方块被分类为“移动物体”。

是时候渲染一些帧了。

渲染循环(这次是真的)

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

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

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

注意

如果只播放静态的等待动画,则该动画被视为“冻结帧”动画。有关更多详细信息,请参见上面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)。换句话说,时间进度保存了时间线上的时间戳(相对于当前动画,从0开始,到动画总运行时间结束,步长由渲染帧率决定),这些时间戳指示了应该渲染新动画帧的时间点。

  • 然后场景会随着时间进展进行迭代:对于每个时间戳 t,会调用 Scene.update_to_time(),该函数…

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

    • 然后(按照动画传递给Scene.play()的顺序) 调用Animation.update_mobjects()来触发所有附加到相应动画的更新函数, 除了动画的“主要对象”(例如,对于Transform,起始和目标对象的未修改副本——更多详情请参见Animation.get_all_mobjects_to_update()),

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

    • 在所有传递的动画都被处理之后,场景中所有mobjects的更新函数、所有网格,以及最后附加到场景本身的更新函数都会运行。

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

注意

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

要渲染图像,场景会调用其渲染器的相应方法, CairoRenderer.render() 并仅传递移动的mobjects列表(记住, 静态的mobjects已经被假定为静态地绘制到场景的背景中)。然后,当渲染器通过调用 CairoRenderer.update_frame() 更新其当前帧时,所有繁重的工作都会发生:

首先,渲染器通过检查是否已经存储了与None不同的static_image来准备其Camera。如果有,它通过Camera.set_frame_to_background()将图像设置为相机的背景图像,否则它只需通过Camera.reset()重置相机。然后,相机被要求通过调用Camera.capture_mobjects()来捕捉场景。

这里的内容有点技术性,有时候深入研究实现会更有效——但这里是一个总结,一旦相机被要求捕捉场景,会发生什么:

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

  • Depending on the type of the currently processed batch, the camera uses dedicated display functions to convert the Mobject Python object to a NumPy array stored in the camera’s pixel_array attribute. The most important example in that context is the display function for vectorized mobjects, Camera.display_multiple_vectorized_mobjects(), or the more particular (in case you did not add a background image to your VMobject), Camera.display_multiple_non_background_colored_vmobjects(). This method first gets the current Cairo context, and then, for every (vectorized) mobject in the batch, calls Camera.display_vectorized(). There, the actual background stroke, fill, and then stroke of the mobject is drawn onto the context. See Camera.apply_stroke() and Camera.set_cairo_context_color() for more details – but it does not get much deeper than that, in the latter method the actual Bézier curves determined by the points of the mobject are drawn; this is where the low-level interaction with Cairo happens.

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

在我们的玩具示例中,渲染循环的简要说明如下:

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

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

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

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

完成渲染循环

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

注意

请注意,作为Animation.finish()的一部分,Animation.interpolate()方法会以1.0的参数被调用——你可能已经注意到,动画的最后一帧有时可能会有点偏差或不完整。这是当前的设计!在渲染循环中渲染的最后一帧(并在渲染的视频中显示1 / frame_rate秒)对应于动画结束前1 / frame_rate秒的状态。为了在视频中也显示最后一帧,我们需要在视频中再添加1 / frame_rate秒——这意味着1秒的Manim渲染视频会略长于1秒。我们在某个时候决定不这样做。

最后,时间进度条在终端中关闭(这完成了显示的进度条)。随着时间进度条的关闭,Scene.play_internal() 调用完成,我们返回到渲染器,现在渲染器命令 SceneFileWriter 关闭为此动画打开的影片管道:部分影片文件被写入。

This pretty much concludes the walkthrough of a Scene.play call, and actually there is not too much more to say for our toy example either: at this point, a partial movie file that represents playing the ReplacementTransform has been written. The initialization of the Dot happens analogous to the initialization of blue_circle, which has been discussed above. The Mobject.add_updater() call literally just attaches a function to the updaters attribute of the small_dot. And the remaining Scene.play() and Scene.wait() calls follow the exact same procedure as discussed in the render loop section above; each such call produces a corresponding partial movie file.

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

就这样!这是对Manim内部工作原理的或多或少详细的描述。虽然我们在这个教程中没有详细讨论每一行代码,但它仍然应该让你对库的总体结构设计以及特别是Cairo渲染流程有一个相当好的了解。