§8 · Анимации

AnimaGeo-сцена — это полноценный manim.Scene. Анимация строится через keyframe-интерполяцию (play_keyframes), явный ValueTracker-updater или комбинацию. Размеры точек, толщины и шрифты остаются pixel-invariant в видео: отрендеренный в 854×480 тест визуально неотличим от 1920×1080 продакшена, отличается только детализацией.

Интерактивный render-сервер этого гайда отдаёт статические SVG — анимации невозможно «проиграть» через кнопку Run. Для MP4 используется manim CLI (см. §9). MP4-превью ниже — встроенные <video>-теги с уже отрендеренными файлами.

Статика vs MP4

АспектSVG (exportSVG)MP4 (manim-CLI)
Подписиодноразово autoPlaceLabels()keyframe_snapshots + интерполяция, или per-frame tracker
Углыбиссектриса один разdynamic_angles → аналитический пересчёт каждый кадр
Z-indexчитается на экспортепереустановка при updateGeoElements
ptUnitфиксирован после applyStyleфиксирован; изменение камеры не пересчитывает размеры
Холстпроизвольный (px_size)manim-canvas из config.pixel_width/height
MP4 использует config.pixel_width/pixel_height, не style.export.ptWidth. applyStyle согласует через соотношение сторон. Рекомендация: px_size пропорционально manim-canvas (1920×1080 для -qh, 854×480 для -ql).

Show / Hide / Update

Базовые строительные блоки анимации. Все возвращают список Animation; «Play»-варианты оборачивают в self.play(...).

МетодЧто делает
Show(names, mode='Fade'|'Create')Показать элементы: Fade — мгновенное появление с прозрачностью; Create — рост (для линий).
Hide(names)Скрыть.
Update(names)FadeOut → пересоздать → FadeIn. Для случая, когда координаты изменились вне анимации.
Shade(names)Затенить (цвет заменяется на col_shade).
Restore(names)Восстановить после Shade.
ShowCreate(name)Тип-зависимый: Create для линий, Fade для заливок.
self.playShow(['A', 'B', 'C'], mode='Create')
self.playHide(['poly'])
self.playShade(['A', 'B'])
self.playRestore(['A', 'B'])

Build-up: поэтапное появление

AnimShowBuildup · manim -ql anim_scenes.py
self.putCode("""
A = Point(-3.5, -1.8)
B = Point(3.5, -1.8)
C = Point(0.5, 2.5)
p, a, b, c = Polygon(A, B, C)
alpha = Angle(B, A, C)
""")

# Сначала скрыть всё
for el in self.geo.elements:
    el.visible = False
self.addAllGeometry(show=False)

# Поэтапное появление
self.playShow(['A', 'B', 'C'], mode='Fade')
self.playShow(['a', 'b', 'c'], mode='Create', run_time=1.2)
self.playShow(['p'], mode='Fade')
self.playShow(['alpha'], mode='Create')
Скачать: AnimShowBuildup.mp4

Keyframe-анимация

play_keyframes(json) интерполирует координаты, curve/locus-параметры (tparam) и видимость элементов между кадрами. Перед стартом playback сцена приводится к состоянию первого keyframe: применяются его values и show/hide, перестраивается геометрия и обновляются mobject-ы. Поэтому первый кадр рендера соответствует keyframe 0, а не сохраненному состоянию файла .ggb.

Формат JSON

{
  "keyframes": [
    {"t": 0, "values": {"A": [0, 0], "x": 35, "D": {"tparam": 0.0}}},
    {"t": 2, "values": {"A": [4, 4], "x": 110,
                          "D": {"tparam": 3.14, "direction": "ccw"}},
              "show": ["line1"], "hide": ["aux"], "easing": "smooth"},
    {"t": 4, "values": {"A": [0, 0], "x": 35}}
  ]
}

Независимые элементы

Что можно анимировать — всё, что возвращает scene.get_independents():

TypeJSON-форматИнтерполяция
free_point[x, y]линейная по координатам
tparam_point (на окружности){"tparam": rad, "direction": "short"/"cw"/"ccw"}угловая
tparam_point (на сегменте/линии/луче){"tparam": 0..1}линейная
number / measurefloatлинейная
anglefloat (rad)линейная
booleantrue/falsesnap на t=0.5
JSON wire-format для constrained-точек использует тип 'tparam_point' и ключ "tparam".

Easing функции: linear, smooth (default), in, out, in_out.

Пример 1 — точка на окружности

AnimCirclePoint
self.putCode("""
O = Point(0, 0); R = Point(3, 0)
c = Circle(O, R)
P = Point(c)                  # tparam-точка на окружности
Q = Point(-3, 0)
rOQ = Segment(O, Q); rOP = Segment(O, P)
s = Segment(Q, P); alpha = Angle(Q, O, P)
""")
self.geo.update_tparam('P', np.pi / 6)
self.geo.rebuild()

# вспомогательные радиусы — серым
for n in ('rOQ', 'rOP'):
    self.element(n).style['stroke'] = '#b1b3b6'
    self.element(n).style['stroke_width_px'] = 0.8

# O статична — подпись фиксируем вручную снизу
self.element('O').style.update({
    'offset': [0, -16], 'label_anchor': 'TC',
    'label_placement_locked': True,
})

# per-frame решатель: P движется по дуге, линейная
# интерполяция offset между keyframes давала бы промах
self.style_config.overlay.label_placement = {
    'enabled': True, 'dynamic_angles': True,
    'distance_px': 6, 'ema_alpha': 0.25,
    'anchor_flip_frames': 10, 'solver_every_n_frames': 2,
}
self.autoPlaceLabels(dynamic=True)

# ValueTracker + sentinel updater
from manim import ValueTracker, linear, Mobject
a_param = ValueTracker(np.pi / 6)
self.add(a_param)
scene = self
def on_frame(mobj):
    scene.geo.update_tparam('P', a_param.get_value())
    scene.updateGeoElements(scene.geo.rebuild())
    if scene._label_tracker is not None:
        scene._apply_dynamic_labels()
sentinel = Mobject(); sentinel.add_updater(on_frame); self.add(sentinel)

target = 2 * np.pi + np.pi / 6 - 0.001
self.play(a_param.animate.set_value(target), run_time=5, rate_func=linear)
sentinel.clear_updaters()
self.clearLabelTracker()
Скачать: AnimCirclePoint.mp4
Подписи и дуга угла α плавно следуют за P благодаря autoPlaceLabels(dynamic=True) и dynamic_angles=True. Без них подписи остались бы в стартовых позициях, а дуга угла — с неверным углом.

Пример 2 — вершина треугольника скользит по прямой

AnimMovingVertex
self.putCode("""
A = Point(-3.5, -1.8); B = Point(3.5, -1.8)
L1 = Point(-3.5, 2.0); L2 = Point(3.5, 2.0)
rail = Line(L1, L2)
C = Point(rail)          # C — tparam-точка на прямой
p, a, b, c = Polygon(A, B, C)
alpha = Angle(B, A, C); beta = Angle(A, B, C)
""")
self.element('C').tparam = 0.0
for n in ('rail', 'L1', 'L2'):
    self.element(n).visible = False

# Для play_keyframes — keyframe_snapshots (не dynamic=True).
self.style_config.overlay.label_placement = {
    'enabled':             True,
    'keyframe_snapshots':  True,
    'dynamic_angles':      True,
    'canonicalize_anchor': True,
}
self.autoPlaceLabels()

self.play_keyframes({
    "keyframes": [
        {"t": 0, "values": {"C": {"tparam": 0.2}}},
        {"t": 2, "values": {"C": {"tparam": 0.8}}},
        {"t": 4, "values": {"C": {"tparam": 0.2}}},
    ]
})
Скачать: AnimMovingVertex.mp4

Keyframes v2: стили, видимость, эффекты

С "version": 2 ключевой кадр может нести styles — целевое состояние стиля любого элемента (числа интерполируются, цвета — в Oklab, дискретные ключи переключаются на середине), в том числе stroke_dash_period_px и stroke_dash_ratio. Появление и исчезновение — эффекты внутри интервала (enter: fade, create, grow, write; exit: fade, uncreate, shrink). grow и shrink масштабируют геометрию вокруг её центра, а подпись остаётся на месте и плавно проявляется или гаснет.

{"version": 2, "keyframes": [
  {"t": 0, "visible": {"c": false}, "styles": {"a": {"stroke_dash_period_px": 10}}},
  {"t": 2, "visible": {"c": true}, "enter": {"c": {"effect": "grow", "duration": 0.8}},
   "styles": {"a": {"stroke_dash_period_px": 20}}}
]}

Полное описание (камера, события, поэтапное построение) — docs/keyframes.ru.md.

addUpdater и ValueTracker

Для тонкого контроля — когда play_keyframes слишком декларативен или нужно связать несколько движений — используется updater: функция, которая вызывается каждый кадр и читает текущее значение ValueTracker.

from manim import ValueTracker, linear

x = self.addVar('x', 0)          # обёртка над ValueTracker
self.autoPlaceLabels(dynamic=True)  # per-frame решатель подписей
self.addUpdater(x)                # связывает x с перестройкой

self.play(x.animate.set_value(1), run_time=3, rate_func=linear)

self.clearUpdater(x)
self.clearLabelTracker()

Что происходит:

  1. addVar('x', 0) создаёт независимую переменную x со стартовым значением 0.
  2. addUpdater(x) ставит per-frame hook: при изменении x вызывается update_var('x', new_value) и rebuild(), пересчитывая все зависимые элементы.
  3. autoPlaceLabels(dynamic=True) ставит LabelTracker: подписи пересчитываются каждые solver_every_n_frames кадров, сглаживаются EMA, флипы якоря проходят hysteresis.
  4. play(x.animate.set_value(1)) — стандартный manim-анимационный вызов; manim сам тикает x от 0 до 1, ValueTracker триггерит updater.
AnimUpdater · тот же pattern
# DSL-конструкция, где x — "угол" отдельного луча
self.putCode("""
O = Point(0, 0)
A = Point(cos(x), sin(x))       # x — переменная: радиан
s = Segment(O, A)
""")

x = self.addVar('x', 0)
self.autoPlaceLabels(dynamic=True)
self.addUpdater(x)

self.play(x.animate.set_value(np.pi), run_time=3, rate_func=linear)

self.clearUpdater(x)
self.clearLabelTracker()
Скачать: AnimUpdater.mp4

Трекинг подписей

Три режима, уже упомянутые в §6:

РежимАктивируетсяПодходит для
Snapshotoverlay.label_placement.enabled=true + keyframe_snapshots=true (без enabled не работает и не трогает ручные смещения)play_keyframes — решатель запускается только на keyframes, offset между ними интерполируется.
Dynamic trackerautoPlaceLabels(dynamic=True)addUpdater-анимации — решатель каждые N кадров, EMA-сглаживание, hysteresis на якорях.
Static frozenautoPlaceLabels()One-shot для SVG или если подписи не движутся.

Рекомендуемая конфигурация для keyframe-анимации

"rendering": {
    "label_placement": {
        "enabled":             true,
        "keyframe_snapshots":  true,
        "dynamic_angles":      true,
        "canonicalize_anchor": true,
        "interpolation":       "smooth"
    }
}

Рекомендуемая конфигурация для addUpdater

"rendering": {
    "label_placement": {
        "enabled":               true,
        "dynamic_angles":        true,
        "ema_alpha":             0.2,
        "anchor_flip_frames":    6,
        "solver_every_n_frames": 2
    }
}

Плюс в коде: self.autoPlaceLabels(dynamic=True).

Как запустить MP4-рендер

SVG пишется из exportSVG. MP4 — из manim-CLI:

$ cd /path/to/animageo
$ PYTHONPATH=. manim -ql my_scene.py MyScene
# -ql = quick low (854×480, 15 fps) — для iteration
# -qh = quick high (1920×1080, 60 fps) — для продакшена
# --media_dir /tmp/animageo — куда складывать temp-файлы
# --disable_caching — пересчитать всё (обычно manim кэширует кадры)

Итоговый файл окажется в media/videos/<script>/480p15/MyScene.mp4.

Обратите внимание: когда в одном Python-файле описано несколько Scene-подклассов, manim берёт их имена из CLI: manim -ql scenes.py SceneA SceneB SceneC отрендерит три отдельных MP4. Это полезно для пакетной регенерации примеров гайда.