在 Manim
中制作与参考系相关的数学动画,首先要了解 manim
中数学坐标系相关的 Mobject 用法,其次还牵涉到其余的个别 Mobject
的使用。学会了 Mobject 还不够,你得让坐标系动起来吧?那就要会使用
Animations 类,最好通读一遍文档。完成了这些还不够,Animations
类没法制作动态跟踪的动画,如果需要让某个在坐标上移动,并且同步显示其位置还需要学会
mainm 中 valueTracker 的使用。
关于 Animations
在我的一篇文章中有过介绍,不详细介绍了。本文主要来探讨一下坐标系(Axes)及
ValueTracker 的搭配使用,从零开始构建一个数学参考系的函数动画演示。
下文中为了方便尽量使用self.add()
方法。本质上Animations
类中的方法如出一辙,在学习阶段并不是说一定要用到。self.add()
已经足够快捷方便了。
本文中所有内容均完全基于 manim community
参考手册,独立原创。
Manim Title
坐标轴 Axes
一个数学动画由哪些元素构成?首先得有平面坐标系吧,坐标系肯定有各种图例和标签符号,这些都是
Mobject。我们先来研究研究坐标系的详细使用方法,再去探讨函数图像的绘制,最后我们讨论一些特殊的
Mobject 怎么用(例如箭头、数字等),在文章末尾我们将他们串连在一起,用
valueTracker 实现动态的渲染。
要了解坐标系,最好的办法当然是查阅一手资料 – 官方文档 。就在手册的
Reference Manual > Mobjects > graphing >
coordinate_systems 下,我们主要来看 Axes 和 Coordinate System
怎么用。
构造方法
参考文档
先来看看 Axes 类的构造方法:
1 2 3 class Axes (x_range=None , y_range=None , x_length=12 , y_length=6 , axis_config=None , x_axis_config=None , y_axis_config=None , tips=True , **kwargs)
我们大致可以看出,Axes 在构造时可以设置 x 轴与 y
轴的长度、取值范围、传递参数、提示等信息。
官方参数说明
这是一段朴实无华的 Axes 构造生成的轴:
朴实无华且单调,但不是递增…
1 2 3 4 5 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes() self .add(axe)
我们给他加上取值范围:
1 2 3 4 5 6 7 8 9 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes( x_range=[1 , 10 , 1 ], y_range=[-1 , 10 , 2 ] ) self .add(axe)
我们令tips=False
,发现箭头没了:
1 2 3 4 5 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes(x_range=[1 , 10 , 1 ], y_range=[-1 , 10 , 2 ], tips=False ) self .add(axe)
加上配置项axis_config={"include_numbers": True}
便有了数字:
1 2 3 4 5 6 7 8 9 10 11 12 13 from manim import * class SingleScene (Scene ): def construct (self ): axe = Axes( x_range=[1 , 10 , 1 ], y_range=[-1 , 10 , 2 ], axis_config={"include_numbers" : True }, tips=False ) self .add(axe)
加上y_axis_config={"scaling": LogBase(custom_labels=True)},
会发现y
坐标有了科学计数法:
1 2 3 4 5 6 7 8 9 10 11 12 13 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes( x_range=[1 , 10 , 1 ], y_range=[-1 , 10 , 2 ], axis_config={"include_numbers" : True }, tips=False , y_axis_config={"scaling" : LogBase(custom_labels=True )}, ) self .add(axe)
需要注意的是,这里启动了 y
轴的对数刻度显示,也就是在轴上的任何函数都是基于对数的形式绘制的。
我们修改一下范围,就会得到官方文档中的样式:
对数图像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes( x_range=[0 , 10 , 1 ], y_range=[-2 , 6 , 1 ], axis_config={"include_numbers" : True }, y_axis_config={"scaling" : LogBase(custom_labels=True )}, tips=False , ) graph = axe.plot(lambda x: x ** 2 , x_range=[0.001 , 10 ], use_smoothing=False ) self .add(axe, graph)
尽管这里的 lambda 表达式中写了
x ** 2
,但函数实际上绘制了l o g x 2 。
我们把axis_config
的配置项改为{"include_numbers": True, 'tip_shape': StealthTip},
会发现轴线的指示箭头改变了。
箭头底部向上凹
接下来我们稍微快一点。我们可以在图中加入一个NumberPlane
对象来产生网格坐标背景,并将绘制出的函数颜色改为红色使之更加鲜明。
开始大刀阔斧地改造
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from manim import *class SingleScene (Scene ): def construct (self ): plane = NumberPlane() axe = Axes( x_range=[0 , 10 , 1 ], y_range=[-2 , 6 , 1 ], axis_config={"include_numbers" : True , 'tip_shape' : StealthTip}, y_axis_config={"scaling" : LogBase(custom_labels=True )}, ) graph = axe.plot(lambda x: x ** 2 , x_range=[0.001 , 10 ], use_smoothing=False , color=RED) self .add(axe, graph, plane)
类方法
我们通过一个例子来讲解一下几个类方法的作用:
1 2 3 4 5 6 7 8 9 10 11 12 from manim import *class SingleScene (Scene ): def construct (self ): plane = NumberPlane() axe = Axes().add_coordinates() dot = Dot((2 , 2 , 0 ), color=GREEN) dot2 = Dot(axe.coords_to_point(2 , 2 ), color=WHITE) graph = axe.plot(lambda x: x ** 2 , x_range=[0.001 , 10 ], use_smoothing=False , color=RED) self .add(axe, graph, plane, dot, dot2)
在这段代码中,用 add_coordinates()
为白色的十字数轴添加了数字的坐标轴指示,并创建了两个 Dot
点对象。你会明显看到两个点的坐标都是(2,2)
但其位置不同。
直接根据点的构造方法创建的绿点,其位置的(2,2)
是相对于plane
而言的,也就是整个平面坐标系
coordinates。通过coords_to_point
就可以将默认的coordinate
坐标转换成数轴上的(2,2)
坐标,也就是白点实现的效果。
另外,我们还删除了原有Axe
中的配置项参数,如果自定义范围会导致数轴不在参考系的中间,或者说不在屏幕中心。
我们通过get_lines_to_point
来获得一个到达目标目标点的虚线(我们常常会画的辅助线),这里获取点的坐标并没有直接采用dot
,而是通过axe.c2p(2,2)
来得到一个
“coordinate_to_point” 的点,注意这里为缩写。
1 2 3 4 5 6 7 8 9 10 11 from manim import *class SingleScene (Scene ): def construct (self ): plane = NumberPlane() axe = Axes().add_coordinates() dot = Dot((2 , 2 , 0 ), color=GREEN) dot2 = Dot(axe.coords_to_point(2 , 2 ), color=WHITE) lines = axe.get_lines_to_point(axe.c2p(2 ,2 )) graph = axe.plot(lambda x: x ** 2 , x_range=[0.001 , 10 ], use_smoothing=False , color=RED) self .add(axe, graph, plane, dot, dot2, lines)
我们来为x 和y 轴加上他们的标签:
利用axe.get_x_axis_label()
我们可以直接利用axe
得到一个确定好位置的轴线坐标,他是一个
label 对象,避免了单独创建一个 label 并调整位置。
同理,也可以使用官网中的get_axis_labels()
方法,传入两个Tex
对象即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from manim import *class SingleScene (Scene ): def construct (self ): plane = NumberPlane() axe = Axes().add_coordinates() dot = Dot((2 , 2 , 0 ), color=GREEN) dot2 = Dot(axe.coords_to_point(2 , 2 ), color=WHITE) lines = axe.get_lines_to_point(axe.c2p(2 ,2 )) graph = axe.plot(lambda x: x ** 2 , x_range=[0.001 , 10 ], use_smoothing=False , color=RED) x_label = axe.get_x_axis_label(Tex('x' )) y_label = axe.get_y_axis_label(Tex('y' ).scale(2 )) self .add(axe, graph, plane, dot, dot2, lines, x_label, y_label)
我们来画一个圆,并显示其位置。圆心向上平移两个单位,白点为圆最右端点。np.around
用于保留小数。
1 2 3 4 5 6 7 8 9 10 11 from manim import *class SingleScene (Scene ): def construct (self ): plane = NumberPlane() axe = Axes(x_range=[0 ,10 ,2 ]).add_coordinates() circ = Circle().shift(UP*2 ) coords = np.around(axe.point_to_coords(circ.get_right()), decimals=2 ) label = (Matrix([[coords[0 ]], [coords[1 ]]]).next_to(circ, RIGHT)) self .add(axe, circ, Dot(circ.get_right()), plane, label)
坐标系 CoordinateSystem
CoordinateSystem 是 Axes
的抽象基类。
文档中一上来就给出了一个相对复杂的例子,我自己也实现了一遍,实际上还是用的
Axes 中的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 from manim import *class SingleScene (Scene ): def construct (self ): axe = Axes( x_range=[0 ,1 ,0.05 ], y_range=[0 ,1 ,0.05 ], x_length=10 , y_length=5 , axis_config={ "numbers_to_include" : np.arange(0 , 1 , 0.1 ), "font_size" : 24 }, tips=False ) label_x = axe.get_x_axis_label("x" ) label_y = axe.get_y_axis_label("y" , direction=LEFT, edge=LEFT, buff=0.4 ) labels = VGroup() labels.add(label_x) labels.add(label_y) lines = VGroup() for i in np.arange(1 , 20 +0.5 , 0.5 ): labels.add(axe.plot(lambda x: x**i, color=BLUE)) labels.add(axe.plot(lambda x: x**(1 /i), color=PINK)) dot = Dot(axe.c2p(1 , 1 ), color=YELLOW) p_line = axe.get_lines_to_point(axe.coords_to_point(1 ,1 )) title = Title( r"Graphs of $y=x^{ {1}\over{n} }$ and $y=x^n (n=1,2,3,...,20)$" , include_underline=False , font_size=40 , ) self .add(axe, lines, labels, dot, p_line, title)
仍然是使用 Axes
来代表坐标系,用到了几个经常出现的方法和函数。VGroup
用于逻辑上将几个
Mobjects
放在一起处理,不影响其本身的属性。用c2p
来生成数轴的坐标点,coords_to_point
是其全拼本质上一致。
他提供的方法较多:
我们着重挑几个比较有意思的玩玩。
CoordinateSystem.get_T_label()
1 2 3 4 5 6 7 8 9 10 11 from manim import *class SingleScene (Scene ): def construct (self ): axes = Axes(x_range=[-10 , 10 ], y_range=[-1 , 10 ], x_length=9 , y_length=6 ).add_coordinates() func = axes.plot(lambda x: x*x, color=BLUE) t_label = axes.get_T_label(x_val=4 , graph=func, label=Tex("x-value" )) self .add(axes, func, t_label)
tLabel 就是图中黄色的线+xvalue
的标签组合,只需要指定一个x
坐标,并选择一条函数曲线即可。
CoordinateSystem.get_area()
可以获取函数和坐标中之间的面积。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from manim import *class SingleScene (Scene ): def construct (self ): axes = Axes(x_range=[-10 , 10 ], y_range=[-1 , 10 ], x_length=9 , y_length=6 ).add_coordinates() func = axes.plot(lambda x: np.sin(x*PI/2 ), color=BLUE) t_label = axes.get_T_label(x_val=5 , graph=func, label=Tex("x-value" )) area = axes.get_area( func, ) self .add(axes, func, t_label, area)
还可以修改其颜色、透明度、范围来达到这样的效果:
1 2 3 4 5 6 7 area = axes.get_area( func, opacity=.4 , color=GREEN, x_range=(-1 , 2 ) )
CoordinateSystem.get_graph_label()
这个方法用于获取坐标系中的图例,比如修改前面
get_area 中的代码,增加一个
label 。在这里我们这只为了带点的标签,通过
label 属性设置其内容,graph
设置其作用的函数,用 x_val
指定了在函数上的位置,direction
为偏移方向,以及颜色等等。
1 2 3 4 5 6 7 8 9 10 11 12 label = axes.get_graph_label( graph=func, label=MathTex(r"1.6" ), dot=True , x_val=1.6 , direction=UP, color=YELLOW ) self .add(axes, func, t_label, area, label)
CoordinateSystem.get_horizontal_line()
得到一条从点到y 轴的水平线,对应的是
get_vertical_line()
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from manim import *class SingleScene (Scene ): def construct (self ): axes = Axes(x_range=[-10 , 10 ], y_range=[-1 , 10 ], x_length=9 , y_length=6 ).add_coordinates() func = axes.plot(lambda x: np.sin(x*PI/2 ), color=BLUE) point = axes @ (2 , 1 ) dot = Dot(point) line = axes.get_horizontal_line(point, line_func=Line) self .add(axes, func, dot, line)
注意这里的第一个参数是point
不是 dot
对象。@
表示 python 中的矩阵运算。
传入参数line_config={"dashed_ratio": 0.85}
设置虚线并调整图线比例。
CoordinateSystem.get_lines_to_point()
同时得到垂直和水平的垂线。
1 2 3 4 5 6 7 8 9 10 11 12 13 from manim import *class SingleScene (Scene ): def construct (self ): axes = Axes() circ = Circle(color=DARK_BLUE).shift(DL*2 ) point = Dot(circ.get_right()) right_line = axes.get_lines_to_point(circ.get_right()) corner_line = axes.get_lines_to_point(circ.get_corner(DL)) self .add(axes, circ, point, right_line, corner_line)
只需提供点的坐标即可。
CoordinateSystem.get_riemann_rectangles()
为曲线生成黎曼矩形。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from manim import *class SingleScene (Scene ): def construct (self ): axes = Axes().add_coordinates() func = axes.plot(lambda x: x**2 ) rects = axes.get_riemann_rectangles( func, dx=0.25 , input_sample_type="right" , x_range=[-3 , -1 ] ) self .add(axes, func, rects)
input_sample_type
用于设置黎曼矩形和曲线接触的点为右上角端点,默认为左上角。dx
设置矩形的宽度,dx
越大,就越大越稀疏。
CoordinateSystem.get_secant_slope_group()
获得切线组,也就是包括割线在内一整个三角形组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from manim import *class SingleScene (Scene ): def construct (self ): ax = Axes(y_range=[-1 , 7 ]) graph = ax.plot(lambda x: 1 / 4 * x ** 2 , color=BLUE) slopes = ax.get_secant_slope_group( x=2.0 , graph=graph, dx=2 , dx_label=Tex("dx = 1.0" ), dy_label="dy" , dx_line_color=GREEN_B, secant_line_length=4 , secant_line_color=RED_D, ) self .add(ax, graph, slopes)
可以根据配置项来确定 x 的起点,水平距离,割线长度等等信息。
CoordinateSystem.get_vertical_line()
获取从 x 轴到曲线的多条线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from manim import *class SingleScene (Scene ): def construct (self ): ax = Axes(y_range=[-1 , 7 ]) graph = ax.plot(lambda x: 1 / 4 * x ** 2 , color=BLUE) slopes = ax.get_vertical_lines_to_graph( graph, x_range=[-2 , 4 ], num_lines=20 ) self .add(ax, graph, slopes)
同上面 get_area 用法类似,不赘述。
CoordinateSystem.get_x_axis_label()
通过 axes 得到x
轴标签,CoordinateSystem.get_y_axis_label()
用法类似,不过多赘述。
1 2 3 4 5 6 7 8 9 10 from manim import *class SingleScene (Scene ): def construct (self ): ax = Axes(y_range=[-1 , 7 ]) graph = ax.plot(lambda x: 1 / 4 * x ** 2 , color=BLUE) x_label = ax.get_x_axis_label(Tex("$x$-values" )) self .add(ax, graph, x_label)
可选参数:
label – 标签。默认为MathTex
forstr
和float
input。
edge – 默认情况下,将添加标签的 y
轴边缘UR
。
direction –
默认情况下,允许从边缘进一步定位标签UR
buff – 标签与线的距离。
返回对应函数上某个点的坐标。
1 2 3 4 5 6 7 8 9 10 11 12 from manim import *class SingleScene (Scene ): def construct (self ): ax = Axes(y_range=[-1 , 7 ]) graph = ax.plot(lambda x: 1 / 4 * x ** 2 , color=BLUE) pos = ax.input_to_graph_point(x=PI, graph=graph) square = Square(side_length=1 ).move_to(pos) self .add(ax, graph, square)
CoordinateSystem.plot()
返回一个曲线,并不会直接绘制需自行赋值并添加。
参数:
function – 用于构造的函数ParametricFunction
。
x_range –
曲线沿轴的范围。x_range = [x_min, x_max, x_step]
use_vectorized – 是否将生成的 t
值数组传递给函数。仅当你的函数支持时才使用此选项。输出应为形状为[y_0, y_1, ...]
colorscale –
函数的颜色。此参数为可选参数,用于根据值对函数着色。传递颜色列表和
colorscale_axis 将根据 y
值对函数着色。传递表单中的元组列表, 允许用户定义颜色过渡的枢轴。(color, pivot)
colorscale_axis – 定义应用颜色比例的轴(0 =
x,1 = y),默认为 y 轴(1)。
kwargs – 要传递给的附加参数ParametricFunction
。
来简单绘制一条 log
曲线,如果定义域内的值难以取到,效果可能不尽人意,这时可以适当调整
use_smoothing 参数来达到更好的效果。
这是一张图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from manim import *class SingleScene (Scene ): def construct (self ): ax = Axes( x_range=[0.001 , 6 ], y_range=[-8 , 2 ], x_length=10 , y_length=5 , ) graph = ax.plot(lambda x: np.log(x), color=BLUE, use_smoothing=False ) pos = ax.input_to_graph_point(x=PI, graph=graph) dot = Dot(pos, color=RED) square = Square(side_length=1 ).move_to(pos) self .play(Create(ax), Write(graph), Write(square), Write(dot))
ValueTracker 绘制
ValueTracker 解决了什么问题:在绘制动画时,我们往往使用
self.play()
方法一步一步的绘制。如果一个数值在每一帧都需要修改其动画怎么办?这就需要用到
valueTracker 来自动更新动画了。
在没有 tracker 时,我们操作一个矩形移动需要直接操作矩形本身。有了
tracker
之后我们就能像开发游戏一样,直接操作一个变量,系统自动根据变量更改矩形的动画。简而言之,大大方便了动画的制作。
valueTracker
仅仅是一个用于存储值的简单对象,将其理解为一个变量就行。
我们可以为一个 Mobjects 添加一个更新函数,只要函数中的 tracker
的值发生了改变,manim 会自动为我们执行函数中的逻辑。例如:
1 2 3 dot = Dot().add_updater( lambda x: x.move_to(tracker.points) )
这里为 dot 设置了一个更新函数,每当函数中的 tracker
发生改变时,自动执行函数中的逻辑 move_to。
为了简单起见,这里不介绍complexValueTracker
,实际上原理相似,自行查看官方文档即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from manim import *class SingleScene (Scene ): def construct (self ): number_line = NumberLine() tracker = ValueTracker(0 ) arrow = Vector(DOWN) arrow.add_updater( lambda m: m.next_to( number_line.n2p(tracker.get_value()), direction=UP ) ) label = MathTex('x' ).add_updater( lambda l: l.next_to(arrow, UP) ) self .add(number_line, arrow, label) self .play(tracker.animate.set_value(2 )) self .play(tracker.animate.set_value(-2 ))
可以看到,每个绑定了add_updater
的对象都会在 tracker
更新的时候执行 lambda 中的工作。在 self.play 中,我们的 tracker
修改数值是一个插值动画,这意味着 tracker
的值不是一瞬间就修改完成的,而是有过程的。相应的就可以调整 tracker
改变的速度和时间来满足更好的需要。