基于Python的Manim面向对象分析
一、简介
这篇文章是展示我第一次认为对于Manim结构化编程的重要性的,以及我第一次尝试去做短视频时代码展示,其中展示了我整个的思考过程,而且整体问题是很显而易见的——缺少结构、思维混乱。
二、源代码
1、原汁原味代码
下面的代码是我将之前第一次尝试去做小项目时整个的代码,一点也没删,注释是讲解,由于都是由一个一个小动画块组成的,单个看肯定没问题。
2、整体思想
整个项目是想创建一个链表,并添加一个结点。对象选择都是基本的mobject(长方形、箭头、文本等等)动画构造也是基本的移动、创建等。内部的*操作,看过本杰明的应该都知道这种用法,解包操作。整体的逻辑实现是非常简单的,但是结构非常混乱。
下面的代码大体看看就行,不用细究。
"""
下面是我自己面向过程式编程
从上到下一步一步地来进行
想到什么做什么,没有任何操作可言
"""
from manim import*
class Try1(Scene):
def construct(self):
#我们先一步一步地来,整一个长方形
# rectangle=Rectangle(color=BLUE,fill_opacity=1).scale(0.2)
# self.add(rectangle)
#接下来,我想创建5个长方形节点,并且依次排列 印象里有VGroup的方法,现在先不使用这个方式,而是选择使用for循环来解决
# for i in range(-2,3):
# self.add(
# rectangle.copy().set_x(i*1.5)
# )
#接下来,我打算使用本杰明,通过VGroup解包的方式来创建一样的效果
# rectangles=VGroup(*[Rectangle(color=BLUE,fill_opacity=0.5).scale(0.2).set_x(i*1.5) for i in range(-2,3)] )
# for rectangle in rectangles:
# self.add(rectangle)
# 使用这种方法,我觉得还是一种编程范式吧,这样可以对这长方形整体进行打包,而上述循环方式,同样也是可以通过VGroup.add()应该是哈,忘了
# 来进行打包,整体的效果是一样的
#上面的编程格式十分不太美观,不容易去读,再尝试一下美观写法
rectangles=VGroup(
*[Rectangle(color=BLUE,fill_opacity=1).scale(0.2).set_x(i*1.5)for i in range(-2,3)]
)
for rectangle in rectangles:
self.play(Write(rectangle),run_time=0.5)
# 5个节点建立好了,接下来可以思考一下箭头来怎么表示,每个正方形可以通过rectangle[0]的方式来访问,然后再arrow方式来进行创建
# 接下来先创建一个箭头
# arrow=Arrow()
# self.add(arrow)
# 接下来打算细致弄箭头了,需要是从一个长方形的RIGHT到另一个长方形的LEFF
#这里困惑了一段时间,因为箭头头尾不对应,一开始觉得可能是因为scale,导致get_right和get_edge_center(RIGHT)会存在偏差
#可是呢,其实对于Arrow而言,我们一开始在文档中就训练过,问题也是出现了,其实就是因为buff导致的,默认值不是0
#所以只需要将buff设置为0就好了。
#这个问题一方面说明,官方文档例子需要多去练习的————关键时候可能想不起来,另一方面也说明了练习官方文档的必要性————反正我通过deepseek没有解决,最后还是脑海里残存的官方文档中Arrow
#的例子,才想起来的buff控制其举例。现在问问chatgpt去试试,看看它能不能解决
arrows=VGroup(
*[Arrow(start=rectangles[i].get_right(),end=rectangles[i+1].get_left(),color=PURPLE,fill_opacity=1,buff=0)for i in range(0,4)]
)
for arrow in arrows:
self.play(
Write(arrow),
run_time=0.5
)
self.wait()
#我们先创建一个头结点
head=rectangles[0].copy().set_color(RED).rotate(PI/2).set_x(-4.5)
head_arrow=Arrow(start=head.get_right(),end=rectangles[0].get_left(),color=PURPLE,fill_opacity=1,buff=0)
self.play(Write(head))
self.wait()
self.play(Write(head_arrow))
#现在来看,我们都创建好了,还需要的是next指针,初始时,next指针指向头结点
#这个指针打算用长方形和箭头的组合方式来表示,现在我们先创建一个指针实例
next_part1=rectangles[0].copy().set_color(YELLOW).scale(0.8).move_to(ORIGIN)
dot=Dot([0,-1,0])
next_part2=Arrow(start=next_part1.get_bottom(),end=dot.get_center(),fill_opacity=1,color=YELLOW,buff=0)
next=VGroup(next_part1,next_part2).next_to(head,UP,buff=0)
self.play(Write(next))
#整体的样式是构造完成了,接下来需要的是添加文本,要不然谁能知道每个结点指代的什么东西吗?
head_text=Text("Head\nNode").scale(0.2).move_to(head.get_center())
next_text=Text("Next\nPointer",color=GREEN).scale(0.2).move_to(next_part1.get_center())
self.play(Write(head_text),run_time=0.5)
self.play(Write(next_text),run_time=0.5)
#接下来打算给长方形中间整个分隔线,分别用来表示地址和数据域,所以还是通过解包的方式完成
lines=VGroup(
*[Line(start=rectangles[i].get_top(),end=rectangles[i].get_bottom(),color=RED,fill_opacity=1)for i in range(0,5)]
)
for line in lines:
self.play(
Write(line),
run_time=0.5
)
# 接下来需要在各个结点上赋值,本来想用D(data)、A(address)来表示数据域和指针域,看了看课本还是用ei和pi来表示吧
#下面的i越界访问了,可以用双变量表示,但是没太有必要,正常地来就好,等会再看看双变量怎么来写的吧
texts1=VGroup(
*[MathTex(f"e_{i}",color=ORANGE).scale(0.9).align_to(rectangles[i].get_left(),LEFT)for i in range(0,5)]
)
texts2=VGroup(
*[MathTex(f"p_{i}",color=ORANGE).scale(0.8).align_to(rectangles[i].get_right(),RIGHT)for i in range(0,4)]
)
#由于最后一个文本是NULL,所以这里需要再重新写一个
text1=Text("NULL",color=RED,fill_opacity=1).scale(0.2).align_to(rectangles[4].get_right(),RIGHT)
for text in texts1:
self.play(
Write(text),
run_time=0.5
)
for text in texts2:
self.play(
Write(text),
run_time=0.5
)
self.play(Write(text1))
#这差不多就是一些最基础的模型了,接下来我们想要创作的是链表的增添、删减等等,而这里肯定就会包含这些代码的复用,所以说,如何建立起自己的
#一个数据库是十分有必要的,这在你之后的创作过程中是十分有必要的,当然,目前来看,现在只是测试创建,整体上来看还是有些混乱的
#由于Rectangle和Text并没有联动起来,为了方便,将文本去除后再进行其他操作
self.wait(3)
self.play(Unwrite(next_text),Unwrite(text1))
for i in range(0,5):
if i==4:
self.play(Unwrite(lines[i]),Unwrite(texts1[i]),run_time=0.5)
elif i<4:
self.play(Unwrite(lines[i]),Unwrite(texts1),Unwrite(texts2),run_time=0.5)
self.wait(3)
rec=rectangles[0].copy().set(color=ORANGE,fill_opacity=1).scale(0.8).move_to(ORIGIN).to_edge(DOWN,buff=1)
self.play(Create(rec))
self.wait(2)
#接下来需要给这个待插入结点增加一个文本说明
rec_text=Text("待插入结点",color=RED).scale(0.5).next_to(rec,DOWN)
self.play(Create(rec_text))
self.wait(2)
self.play(FadeOut(rec_text))
#接下来,如果我们想插入2、3结点中,应该如何操作呢?
self.play(rec.animate.move_to([0,2.5,0]))
#这里来看,整体位置是没有太大问题的,但是呢,想强行插入的话肯定是不合理的,还需要将整体拆分,然后留出来足够的空间,再将其插入就好
#中间插入 这些代码是一步一步来的,缺少整体性,所以现在存在的问题是,想再重新利用之前代码,发现似乎利用不起来,需要反复地回看
self.play(Unwrite(arrows[1]),run_time=0.5)
动画生成展示
最后的节点插入部分没弄,当时只是觉得思维混乱,也明白了面向对象存在的意义。
本来想用gif放在这,后来发现通过图片来放gif,文件最大只能5M,而我生成的gif,测试时是1s差不多1M,哈哈有点无语。
哎,视频上传到b站,设为仅自己可见,链接放在这直接不行。。。
优酷视频怎么自己上传视频自己分享不了。。。
只能通过云链这种方式了
动画展示
三、分析
我们不是分析代码,而是分析结构。我写的这个代码,虽然包装到类Try1中,表面上是面向对象的,但是类的内部实现是面向过程的。
不知道大家编程能力怎么样,是否知道面向过程的意思。我觉得可以这样理解:想到什么写什么。当我们一开始学习编程时,遇到一个题目,我们会想一步一步如何解决,然后将每一步用代码实现,最后得到的完整代码,这种编程方式就是面向过程。再简单回看一下我的源代码,我下一步想干什么,都提前在注释中写好了,其实就是面向过程的。
所以说,并不是定义了个类,就是面向对象了,而是要看整体结构的。对于manim来说,我们使用的各种mobject、animation等,其实都是封装好个一个一个的类,看过manim官方文档的肯定也知道,内部类的继承关系都十分明确。
当然,我这肯定不是否定面向过程式编程,它也是不可或缺的。只不过面向对象更适用于大的项目,而面向过程,则适用于一个又一个小的问题实现。面向对象中,各个方法(这里的方法指面向对象的方法,简单说其实就是类中定义的函数)的编写,其实也是面向过程的。另外,除了面向过程、面向对象外,还有函数式编程。函数式编程,顾名思义,解剖问题,然后拆分为一个又一个的函数,最后调用函数并解决问题。日常做题中,整体结构一般是面向过程,而解决问题时,为了方便,我们也许会定义一个又一个的函数,避免代码重复编写。
四、Python的面向对象
注意,我这里说的是Python的面向对象,Manim的面向对象跟它还是有一定区别的,二者肯定是以Python面向对象为主体,而Manim面向对象只是其延伸。另外,我这里也仍是将结构,不讲代码。
结合上面第三点的分析,我们知道,结构上来看,面向对象包含着一系列的方法(或者函数),各个方法(或者函数)内部,又有对应面向过程的解决问题方式。简单来看,如果我们单纯地将类视为面向对象、将类的方法视为函数的话,那么面向对象、函数式编程、面向过程可以用以下框架概括:
类1:
对象
函数1:
过程
函数2:
过程
函数3:
过程
类2:
对象
函数1:
过程
函数2:
过程
函数3:
过程
类1()
类2()
面向对象,操作的对象可别忘记了。最下面的两行是类的实例化,也就是调用类,实现对应功能(要不然你只定义了类,不去调用的话,对应的功能也就没有实现。而调用的这个过程,就是类的实例化)。
上面全是文字描述,看着有些乏味,但是是我个人实践中的一些理解,我觉得说的比较明白了,如果还看不明确的话,一定要再多多去看看对于书籍与视频,自己再进行些实践,才能有自己独特的见解。
五、Manim的面向对象
这里也还是只谈论结构,后续会再深入讲解。
前面说过了,Manim的面向对象是对Python面向对象的延伸。我们编写的函数construct,内部每一个动画的编写,其实都调用了类。而这个类,是存储在manim包中,并不是由我们编写的,我们只是编写了construct函数。注意再看一看我上面的Python面向对象的结构,函数式编程内部其实就是面向过程的。所以这么来看,严格意义上来讲,我们编写construct函数,其实是面向过程的,内部各种mobject(类)、animation(类)的调用,是别人写好的包供我们调用的。
当然,编程是灵活的,不是死板的,等熟知之后想怎么写就怎么写,没必要被任何框架束缚。但是在熟知掌握之前,最好还是按照前人总结来写,要不然基础本身就没多少还想按照自己想法来,最后肯定会乱的。
所以说,一开始我上面的写法,完完本本的面向过程写法,可以拆分为好几个类,分别编写对应的construct,也可以在一个类中再编写好几个函数,最后通过construct函数来调用,都是不错的解法。而像我那样,只靠一个类,一个construct函数解决所有问题,随着问题的深入,肯定会越来越混乱的。
最后,我们再看一眼Python面向对象的结构,末尾出现了类1、类2的调用,也就是实例化,而在manim中并没有存在这个过程。其实答案藏在了manim包中。我们来看一看manimce的官方解释。其中的代码注释是我当时第一次理解时写的,这里就不删去了。
"""
说了这么久的oop,我们知道,封装好了一个类,需要通过类的实例来去运行,
可是在manim中,我们只需要在construct中写好对应的代码,然而并没有在全局中
调用对应的实例就可以渲染出图像,为什么呢?
其实很简单,manim自动调用了,举个例子:在某处,一定会有sc=Scene()
然后再经过一系列的接口,从而渲染出对应的图像.这一点其实在manim官方文档中
说明了,感兴趣的可以去看一下.
"""
from manim import *
"""
这是官方文档中的,下面的wtih...是不需要我们显式书写的,这样我们可以明显看出
Secne是如何实例化的.
"""
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()
我觉得应该没必要再多说什么了,我们自定义类,最后总会在manim包中隐式调用的,然后经过渲染,生成了一系列动画。
六、总结
上面内容是我对面向对象结构的分析,后续的话会深入讲解Manim中面向对象代码的解释。如果有什么建议、错误、疑问,欢迎在评论区留言。