目录
本文介绍 Manimgl 开发库中的 FunctionGraph 函数图形类和 ParametricCurve(曾用名:ParametricFunction,参数方程)参数曲线类进行图形绘制。这些图形分别对应于某个曲线的函数或参数方程。这些图像任然是 VMobject 对象,支持动画,也可以作为 MoveAlongPath 类的 path 参数,让其他 VMobject 沿着路径移动。Manimgl 的最新版中还增加了 ImplicitFunction 隐函数类,埋个坑,关于如何绘制隐函数图像下次单独介绍。
一、基本数学概念
先复习函数和参数方程的基本概念。
函数的定义:设A、B是非空的数集,如果按照某个确定的对应关系F,使对于集合A中的任意一个数 x,在集合B中都有唯一确定的数 F(x) 和它对应,那么就称 F:A→B 为从集合A到集合B的一个函数。记作:y=F(x),x∈A。其中,x 叫做自变量,x 的取值范围A叫做函数的定义域;与 x 的值相对应的 y 值叫做函数值,函数值的集合 { F(x) | x∈A } 叫做函数的值域。通过若干个这样的 x,F(x) 值对,Manimgl 就能在平面坐标系中绘制出函数所对应的图形。
参数方程是在取定的平面直角坐标系中,如果曲线上任意一点的坐标 x, y 都是某个变数 t 的函数,并且对于 t 的每个允许值,由图中方程组所确定的点 M(x, y) 都在这条曲线上,那么这个方程组就叫做这条曲线的参数方程。联系 x, y 之间的关系的变数叫做参变数,简称参数。参数方程中的参数可以是有物理或几何意义的变数,也可以是没有明显意义的变数。
相对于参数方程来说,直接给出曲线上点的坐标关系的方程 F(x, y)=0,叫做曲线的普通方程。一般情况下,可以通过消去参数方程中的参数,得到直接表示 x, y 之间关系的普通方程;也可以选择一个参数,将普通方程化成参数方程。同一个普通方程,如果选择的参数不同,可以化成不同的参数方程。概念部分就介绍这么多,具体问题可参考平面解析几何方面的资料。
二、绘制函数图形
通过 FunctionGraph 类,能快速绘制函数图形。该类是 ParametricCurve 的派生类,其主要变化是在构造函数中定义了一个参数方程函数 parametric_function,以 x_range 中的每个值作为参数 x,以该参数方程函数的返回值作为 y 值,将 z 值置0后,作为点坐标向量。事实上是初始化了一个特殊的 ParametricCurve 对象,FunctionGraph 的构造函数代码如下:
def __init__(self, function, x_range=None, **kwargs):
digest_config(self, kwargs)
self.function = function
if x_range is not None:
self.x_range[:len(x_range)] = x_range
def parametric_function(t):
return [t, function(t), 0]
super().__init__(parametric_function, self.x_range, **kwargs)
从以上代码可见,初始化 FunctionGraph 对象时,需要为构造函数提供两个参数,分别是 function 参数,即函数图形所对应的数学函数;x_range 参数,即自变量 x 的取值范围。对 function 参数指向的函数有以下具体要求,首先只能接受一个值类型的参数,代表 x,且只能返回一个值类型的值,代表 y。这符合数学函数的定义,即 x 与 y 一一对应。
以正弦函数为例,绘制其函数图形,先看看代码和最终输出效果:
完整程序代码如下:
class FunctionGraphSin(Scene):
def construct(self):
axes = Axes(
x_range=[-1, 7, 1],
y_range=[-2, 2, 1],
width=8,
height=4,
axis_config={
"include_tip": True,
}
)
sin_graph = FunctionGraph(
function=lambda x: np.sin(x),
x_range=[0, 2 * np.pi, 0.1]
)
sin_graph.move_to(axes.get_origin(), aligned_edge=LEFT)
self.play(Write(axes), run_time=1)
self.play(Write(sin_graph), run_time=1)
self.wait()
重点是以下两行:
sin_graph = FunctionGraph(function=lambda x: np.sin(x), x_range=[0, 2 * np.pi, 0.1])
sin_graph.move_to(axes.get_origin(), aligned_edge=LEFT)
首先创建 FunctionGraph 对象,参数 function 指向一个函数,本文写成 lambda 形式,存粹是为了看到自变量 x,事实上只要写 np.sin 即可(没有括弧),Manimgl 会自动将 x_range 列表中的每一个值都作为参数传入该函数执行,并读取返回值。x_range 参数是列表类型的对象,其含义为 [min, max, step],最小值,最大值,步长。代码的完整含义是:以 0.1 为步长取 0~2π 范围内所有数值作为 x 值,并交由 sin 函数计算出所有的 y 值,将这些值对作为平面坐标系上的点坐标,绘制图形。
其次是移动正弦图形到坐标系的原点,原因是范例中坐标系的原点并不在 0,0 位置,这是由坐标系的 x_range 和 y_range 构造参数决定的。为保持正弦图形在坐标系中处于正确的位置,要把正弦图形的起点位置(范例中 x 值是从0开始的)移动到坐标系的原点。
对于其他函数的图形,可参考以上方式编写。代码中的 v_group 仅为了同步缩小和移动坐标系和函数图形。
三、绘制参数曲线
参数方程是通过传入参数,计算获得一系列的 x,y 值,这种方式对程序代码是很友好的,程序将这些值作为曲线上点的坐标绘制出来,就得到了参数方程的图形。以圆为例,圆的标准方程是:
其对于用计算机程序绘制图形是不太友好的,若将其转换为参数方程,则能更容易地绘制图形,将标准方程转换为参数方程的过程如下:
Manimgl 库中,ParametricCurve 即参数曲线类,正是利用了参数方程的这一特点编写的,其构造函数如下:
def __init__(self, t_func, t_range=None, **kwargs):
digest_config(self, kwargs)
if t_range is not None:
self.t_range[:len(t_range)] = t_range
# To be backward compatible with all the scenes specifying t_min, t_max, step_size
self.t_range = [
kwargs.get("t_min", self.t_range[0]),
kwargs.get("t_max", self.t_range[1]),
kwargs.get("step_size", self.t_range[2]),
]
self.t_func = t_func
VMobject.__init__(self, **kwargs)
要创建 ParametricCurve 的对象,至少提供 t_func 参数,这个参数是指向一个代表参数方程的函数,该函数根据 t_range 范围内的所有值,计算每个 x,y,增加 z 值(该值为0)后,作为点坐标返回。若 t_func 指向的函数不需要参数,可不提供 t_range 参数,一般情况下参数方程都需要传入参数。以上文提到的圆的参数方程为例,以原点为圆心,半径为2绘制圆,输出内容和演示代码如下:
完整代码如下:
class ParametricCircleDemo(Scene):
@staticmethod
def circle(t):
x = 2 * np.cos(t)
y = 2 * np.sin(t)
return np.array([x, y, 0.0])
def construct(self):
axes = Axes(
x_range=[-4, 4, 1],
y_range=[-3, 3, 1],
width=8,
height=6,
axis_config={
"include_tip": True,
}
)
param_curve = ParametricCurve(
t_func=self.circle,
t_range=[0, 2 * np.pi, 0.05],
color=GREEN,
)
self.play(Write(axes), run_time=1)
self.play(Write(param_curve))
self.wait()
从基本使用上看,参数曲线类的使用是比较方便的,本文通过绘制其他参数方程的图像,继续全面深入地展示 ParametricCurve 类的更多特性。
四、绘制双曲线
参考用参数曲线类绘制圆的代码,可以很容易的编写绘制双曲线图像的程序(关于如何推导双曲线的参数方程,可网上查询),对比可发现代码变化很小:
class ParametricCurveDemo(Scene):
@staticmethod
def hyperbolic_curve(t):
x = 1/np.cos(t)
y = np.tan(t)
return np.array([x, y, 0.0])
def construct(self):
axes = Axes(
x_range=[-3, 3, 1],
y_range=[-3, 3, 1],
width=6,
height=6,
axis_config={
"include_tip": True,
}
)
param_curve = ParametricCurve(
t_func=self.hyperbolic_curve,
t_range=[0, 2 * np.pi, 0.001],
color=GREEN
)
v_group = Group(axes, param_curve)
v_group.scale(0.8, about_point=ORIGIN)
self.play(Write(axes), run_time=1)
self.play(Write(param_curve))
self.wait()
仅仅把圆参数方程,改为双曲线的参数方程,为了能更多地看到图像,把坐标和图像缩小了 20%。代码输出图像如下:
显然,图像存在明显问题,代码并没有要求绘制渐近线!事实上,多出来的线也并非是渐近线,而是 ParametricCurve 在绘制图形时没有正确间断,将所有的点作为一个 path 绘制而导致的!通过阅读 ParametricCurve 的 init_points 方法代码可以看到,参数曲线类能按照 discontinuities 列表设置的间断点对 path 进行分段,也就是只要正确设置间断点,就能得到正确的双曲线图像。
所谓间断点,以双曲线为例,就是告诉 ParametricCurve 在4个发散角处断开 path 的绘制,重新创建新的 path 对象,而这4个角对应的是2个 θ 角参数 0.5π 和 1.5π,即90度角和270度角。代码中设置的 t_range 是 0~2π,需要以 0.5π 和 1.5π 作为间断,修改 ParametricCurve 对象的创建代码为:
param_curve = ParametricCurve(
t_func=self.hyperbolic_curve,
t_range=[0, 2 * np.pi, 0.001],
color=GREEN,
discontinuities=[
0.5*np.pi, 1.5*np.pi
],
)
再次执行后,程序能较好地输出双曲线,但是任然有多余的点存在,并且在移动图像时,可以隐约看到还是有多余的线存在!再次分析 ParametricCurve 的 init_points 方法的以下代码:
jumps = np.array(self.discontinuities)
jumps = jumps[(jumps > t_min) & (jumps < t_max)]
boundary_times = [t_min, t_max, *(jumps - self.epsilon), *(jumps + self.epsilon)]
boundary_times.sort()
for t1, t2 in zip(boundary_times[0::2], boundary_times[1::2]):
t_range = [*np.arange(t1, t2, step), t2]
points = np.array([self.t_func(t) for t in t_range])
self.start_new_path(points[0])
self.add_points_as_corners(points[1:])
发现,在计算 boundary_times 时,用到了 self.epsilon 属性。而这部分代码的含义是以 t_range 中的最小值、最大值,以及所有中间值减去和加上 self.epsilon 属性的结果组成一个列表,以这个列表中的值两两为对,分割 t_range 创建 path 并用参数方程计算获得的点集进行填充。
通过分析 self.epsilon 属性的值发现,Manimgl 给的默认值是 1e-8,即10的-8次方,而再看程序中用到的 π 是由 Numpy 库提供的,Numpy 库为 π 保留到小数点后15位。也就是说,尽管有间断分割,但是没有分在 t_range 中的正确值上,这导致了输出图像的问题。
至此,继续修改 ParametricCurve 对象的创建代码为:
param_curve = ParametricCurve(
t_func=self.hyperbolic_curve,
t_range=[0, 2 * np.pi, 0.001],
color=GREEN,
discontinuities=[
0.5*np.pi, 1.5*np.pi
],
epsilon=1e-15,
)
设置 epsilon 属性为 1e-15,再次执行,得到如下图形:
这样,得到了比较正确的双曲线图形,同时也说明了 Manimgl 中参数曲线类的详细使用方法。