这篇的效果如下:
关注公众号: 算法铁金库, 获取完整代码.
可以看到这是一个 3D 动画, 而 Manim 主要还是渲染 2D 动画, 对于 3D 动画的渲染还不是很完美. 所以我们想要用 Manim 做 3D 动画最好还是不要搞得太复杂, 否则会出现许多问题. 如果真是要做很复杂的 3D 动画, 那 Manim 暂时不是很好的选择.
动画的核心思想:
- 给定时间点, 计算出两个小正方体的位置.
- 通过每一帧计算两个正方体的碰撞次数, 如果次数增加, 就渲染撞击动画.
在制作 3D 动画的时候, 强烈建议先画个坐标系, 这样可以更好的帮助我们找到空间中的位置. 代码如下:
# 坐标系
axes = ThreeDAxes(
x_range=[-3, 3, 1], y_range=[-3, 3, 1], z_range=[-3, 3, 1],
x_length=6, y_length=6, z_length=6,
x_axis_config={'include_ticks': True},
y_axis_config={'include_ticks': True},
z_axis_config={'include_ticks': True}
)
labels = axes.get_axis_labels(
Text("x轴").scale(0.5), Text("y轴").scale(0.5), Text("z轴").scale(0.5)
)
self.add(axes, labels)
代码中的 StateTracker 类核心思想就是给定初始时两个正方体的质量和速度, 根据时间计算正方体的位置和碰撞次数. 使用了动能守恒和动量守恒, 不过大家不必过于纠结代码的原理, 只需要知道它的功能即可.
ClackAnimation 类是一个比较实用的类, 它是一个撞击动画, 大家可以存起来以后留着用. 代码如下:
class ClackAnimation(Restore):
def __init__(self, point, run_time=0.25, **kwargs):
circles = VGroup(*[Circle(radius=0.25) for _ in range(3)])
circles.move_to(point)
circles.set_stroke(YELLOW, 0, 0)
circles.save_state()
circles.scale(0.1)
circles.set_stroke(YELLOW, 2, 1)
super().__init__(circles, lag_ratio=0.1, run_time=run_time, **kwargs)
绘制地板和墙, 因为是在 3D 场景下创建, 而 z 轴是向上的, 所以要考虑好物体的摆放位置. 不同于平时的 2D 动画, 在 3D 场景中, UP 表示 Y 轴正方向, DOWN 表示 Y 轴负方向, LEFT 表示 X 轴父方向, RIGHT 表示 X 轴正方向, IN 表示 Z 轴负方向, OUT 表示 Z 轴正方向.
wall.align_to(floor, IN + DOWN) 就表示将z轴负方向和y轴负方向的边缘对齐.
代码如下:
# 地板和墙
def get_floor_and_wall_3d(self):
floor = Surface(
lambda u, v: np.array([u * 1.5, v * 13, 0]),
u_range=[-0.5, 0.5], v_range=[-0.5, 0.5],
fill_color=GREY_D, fill_opacity=1,
resolution=(1, 30)
)
wall = Surface(
lambda u, v: np.array([u * 1.5, 0, v * 2]),
u_range=[-0.5, 0.5], v_range=[-0.5, 0.5],
fill_color=GREY_D, fill_opacity=1,
resolution=(5, 1)
)
# IN表示z轴负方向, DOWN表示y轴负方向, 表示将z轴负方向和y轴负方向的边缘对齐
wall.align_to(floor, IN + DOWN)
result = VGroup(floor, wall)
result.set_fill(GREY_D, 1).set_stroke(GREY_D, 1)
result.move_to(np.array([0, 0, -3]), IN)
return result
绘制两个方块, 代码如下:
def get_block_pair(self, initial_positions):
masses = [10, 1]
colors = [BLUE_E, '#51463E']
widths = [1.0, 0.5]
return Group(*[self.get_block(mass, _color, width, initial_position) for mass, _color, width, initial_position in zip(masses, colors, widths, initial_positions)])
def get_block(self, mass, _color, width, initial_position):
block = Cube(width, 1, _color, 1, stroke_color=WHITE)
# 正方体紧靠地板
block.next_to(self.floor, OUT, 0.1)
block.set_y(self.wall.get_y() + initial_position)
mass_label = Tex(str(mass) + R"\, \text{kg}", font_size=24)
mass_label.next_to(block, OUT, buff=SMALL_BUFF)
mass_label.add_updater(lambda m: m.next_to(block, OUT, buff=SMALL_BUFF))
block.mass_label = mass_label
block.mass = mass
return block
需要注意的是, 正方体上方的两个显示几千克的标签是不会随着相机旋转的, 他们始终正对这我们, 所以需要添加 mass_label.add_updater(lambda m: m.next_to(block, OUT, buff=SMALL_BUFF)), 它保证了位置始终在正方体的上方. construct()方法中的 self.add_fixed_orientation_mobjects(self.blocks[0].mass_label, self.blocks[1].mass_label) 表示物体始终不随相机旋转.
在相机移动方面, 核心代码如下:
self.set_camera_orientation()
self.wait(1)
self.set_camera_orientation(75 * DEGREES, -30 * DEGREES, zoom=1.1, focal_distance=6, frame_center=[-1, -3, -2])
self.wait(1)
self.move_camera(90 * DEGREES, 0, zoom=1, frame_center=[0, 0, 0], focal_distance=12,
added_anims=[CustomTimeAnimation(time_tracker, equations)],
run_time=20, rate_func=linear)
self.wait(1)
Manim 现在的版本似乎是有 bug, 如果不加前两行代码, 后续的移动会有些问题. 所以只能将渲染的动画用剪辑工具减掉前 1 秒了. 这个 bug 就得看官方后续版本会不会修复了.
added_anims 参数中传入了自定义动画, 这个动画的作用就是为了让两行公式可以在滑动方块动画执行快结束的时候显示. 因为我试了许多其他方法, 多少都有点问题, 最后这个自定义动画可算是达成了预期效果. 不得不说 Manim 官方的代码多少有点烂, 许多功能都有 BUG.
可以看到最终渲染的动画会有正方体后面的边框错误的显示在前面的问题, 这也是 Manim 渲染 3D 动画最大的缺陷, 缺少深度测试, 看官方后续会不会修复了. 所以我们在制作 3D 动画的时候不能搞得太复杂.
以上就是本篇全部内容了, 代码有点多, 需要点耐心才能看完. 如有疑问, 欢迎讨论.