Что такое manim

Manim это питоновская библиотека для создания математических анимаций. Есть две версии библиотеки. Одна это community edition, форк оригинальной библиотеки, который развивается сообществом. Вторая это ManimGL, версия, над которой работает Сандерсон. В общем случае версии несовместимы, поэтому если будете смотреть исходники или искать примеры, обращайте на это внимание. Подробности можно почитать здесь. Для начинающих лучше использовать community edition, что я и сделал.

Почему доска Гальтона

Видео от 3blue1brown классные, но кроме самих видео мне было интересно как они сделаны. Быстрый гуглинг показал что видео делаются кодом на питоне, что само по себе интересно. Код писать я могу, поэтому захотелось попробовать самому. Но что именно? Математических видео было много и так, да и я не математик, чтобы делать именно математические видео. Побаловавшись с рисованием графиков я решил сделать анимацию доски Гальтона. Определение из вики - Доска Гальтона — устройство, изобретённое английским учёным Фрэнсисом Гальтоном и предназначающееся для демонстрации центральной предельной теоремы. Шарики падают сверху, в шахматном порядке сделаны препятствия, на каждом препятствии у шарика есть равные шансы отскочить влево или вправо, и так на каждом ряду. Вот так это выглядит на скриншоте, под спойлером гифка.

Доска Гальтона
Доска Гальтона
Гифка с анимацией
Доска Гальтона
Доска Гальтона

На видео анимация выглядит значительно лучше.

Небольшой дисклеймер, на питоне я до этого не писал вообще ничего, профессиональных питонистов прошу понять и простить за возможные корявости.

Установка и простейшая сцена

Устанавливается все как обычно, вот страничка с инструкциями, но есть тонкость. LaTex указан как optional, но лучше его установить сразу. Если латеха нет (и он нужен, что правда бывает не всегда), то при билде вываливается малоинформативная ошибка. Точнее большая куча ошибок, в одной из которых написано про латех. Подробности про рендеринг текста и формул тут, если очень кратко, то manim может рендерить текст и формулы двумя разными способами и латех не всегда нужен. В итоге проще установить сразу, чем всякий разбираться каким именно образом будет рендериться конкретный объект.

Сначала простейший пример. Обычай ездить к соседям печатать "Hello world" не нами выдуман, не нами и окончится, поэтому сначала HelloWorld.

Создаем файл scene.py с таким содержанием

from manim import *

class HelloWorld(Scene):
    def construct(self):

        helloWorld = Text("Hello World")
        self.play(FadeIn(helloWorld, run_time = 3))

В командной строке пишем manim scene.py HelloWorld -pqm --flush_cache и смотрим видео.

Для создания анимации нужен класс с методом construct, класс наследуется от Scene. Все анимации должен быть в этом методе. Можно, конечно, создавать вспомогательные методы, классы и вообще все что нравится, но сами анимации должны быть в методе construct. Вот и все, как видите очень просто.

Что означают параметры командной строки? С HelloWorld и scene.py все очевидно. Флаг -p означает preview, чтобы видео открылось автоматически после рендера. Флаг qm - quality medium (1280x720 30FPS), возможные варианты качества от низкого к высокому - l|m|h|p|k. Флаг --flush_cache очищает кэш. Зачем надо очищать кэш? У меня периодически подглючивал manim, когда после небольших изменений в сцене видео не менялось. После некоторых экспериментов и гугла начал использовать --flush_cache и проблема ушла. Это был типичный гейзенбаг, который то появлялся, то уходил. Необязательно он будет у вас, но такое случается. Полный список флагов тут.

Теперь немного о системе координат и о размерах сцены. По умолчанию размер сцены 8 единиц по высоте и 16 и 2/9 по ширине. Начало координат в середине. Для наглядности поменяем наш HelloWorld как на коде ниже, заодно нарисуем bounding box для текста, это бывает полезно для размещения объектов на сцене. Еще покрасим фон в серый цвет, так будет понятнее размер сцены. Расстояние между точками 1 единица, слева и справа есть немного места, а сверху и снизу точки ровно по краям.

from manim import *

class HelloWorld(Scene):
    def construct(self):

        self.camera.background_color = GRAY

        for x in range(-7, 8):
            for y in range(-4, 5):
                dot = Dot(np.array([x, y, 0]), radius=0.05)
                self.add(dot) 

        ax = Axes(x_range=[-7, 7], y_range=[-4, 4], x_length=14, y_length=8)
        self.add(ax)

        helloWorld = Text("Hello World")

        box = SurroundingRectangle(helloWorld, color=YELLOW)
        self.add(box)

        self.play(FadeIn(helloWorld, run_time = 3))

Вот так это выглядит, пусть немного страшно, зато все понятно с размерами. Я специально сделал скриншот с плейером, чтобы было видно где задний фон сцены, а где задний фон самого плейера. Вроде бы мелочь, но задний фон сцены по умолчанию черный и они сливаются. Из-за этого может быть непонятно где именно границы сцены.

Система координат в manim
Система координат в manim

Доска Гальтона

Основная идея - заранее нарисовать для каждого шарика свою траекторию с отскоками вправо/влево на препятствиях и перемещать по ней шарик в каждом кадре. Еще нужны сами препятствия, общий счетчик и счетчики для каждого столбика. Звучит просто, но были некоторые подводные камни. Дальше я покажу основные моменты, на которые стоит обратить внимание.

Статические объекты

Счетчики для каждого столбика я сделал таблицей, таблиц в manim есть несколько разных. У меня была таблица с целыми числами, поэтому я использовал IntegerTable. Код ниже.

    def createTable(self):
        table = IntegerTable(
            [[0, 0, 0, 0, 0, 0, 0, 0],],
            line_config={"stroke_width": 1, "color": YELLOW},
            include_outer_lines=False
            )
        table.scale(.5)
        table.shift(DOWN * 3.7).shift(LEFT * 3)

        return table

Обратите внимание на вызовы table.scale и table.shift. Для изменения дефолтного размера scale, для изменения дефолтной позиции на сцене shift. Константы DOWN, LEFT (и прочие направления, есть диагонали) это вектора наподобие таких

DOWN: Vector3D = np.array((0.0, -1.0, 0.0))
UL: Vector3D = UP + LEFT

Общий счетчик (Items count: N) создается так

    def createCounter(self):
        counter = Integer(0).shift(RIGHT * 4).shift(DOWN * .6)
        text = Text('Items count:', font_size = 28)
        text.next_to(counter, LEFT)

        return VGroup(counter, text)  

Обратите внимание на text.next_to, таким образов в manim выравниваются объекты. В методе создаются два объекта и объединяются в группу. В дальнейшем можно с этой группой работать как с одним объектом. Это удобно и дальше еще пригодится.

Сама "доска" из шестиугольников рисуется банально, надо только ее выровнять, так чтобы осталось место для столбиков и счетчиков снизу.

Код внутри метода construct, который вызывает методы для создания статических объектов и рисует их на сцене. Объекты появляются на сцене через анимацию FadeIn (есть и FadeOut). Можно просто их добавить на сцену, они появятся сразу, но с анимацией выглядит красивее. Если хочется чтобы все появлялось одновременно, можно все объекты объединить в VGroup.

        table = self.createTable()
        counter = self.createCounter()
        hexagons = self.createHexagons()
		
		self.play(FadeIn(hexagons, run_time = 1))
        self.play(FadeIn(table, run_time = 1))
        self.play(FadeIn(counter, run_time = 1))

        #group = VGroup(hexagons, table, counter)
        #self.play(FadeIn(group, run_time = 1))

        self.wait(3)

Траектории

Жизнь шарика в доске Гальтона простая - появиться над сценой и падать вниз, отклоняясь вправо или влево от удара о препятствие. В итоге упасть в конкретный "стакан" на определенное место в "стакане".

Для расчета траектории надо знать

  • начальную точку (одна и не меняется, задается в конфиге)

  • точки удара о препятствия (много и зависят от размера и координат препятствий), надо рассчитывать исходя из размера и координат шестиугольников

  • место в "стакане" - зависит от того, сколько шариков уже в "стакане", для расчета надо знать сколько шариков уже упало в "стакан"

Для обновления значений нужных счетчиков в нужные моменты, а еще чтобы шарик появлялся в нужное время надо знать

  • номер фрейма, когда шарик появляется и начинает падать, иными словами когда начинается движение шарика по траектории

  • номер фрейма, когда шарик упал в стакан чтобы увеличить общий счетчик

  • номер "стакана", куда упал шарик, чтобы увеличить счетчик шариков в конкретном "стакане"

Я сделал специальный класс для шарика, в котором хранится все (почти) перечисленное выше

class Item:
    circle = None
    path = None
    startFrame = 0
    stackIndex = 0
    isActive = True

circle - объект manim для отображения на сцене, создается одинаково для всех шариков

circle = Circle(radius=circleRadius, color=GREEN, fill_opacity=1)

path - траектория шарика, создается из кусочков линий и кривых вот так (код сильно упрощен, оставлено главное)

    path = Line(firstDot, nextDot, stroke_width=1)
	for (some condition):
		pathTmp = ArcBetweenPoints(previousDot, nextDot, angle=-PI/2, stroke_width=1)
		path.append_vectorized_mobject(pathTmp)

А главное здесь это path.append_vectorized_mobject(pathTmp). Для траектории нужна одна непрерывная кривая и этот метод позволяет собрать непрерывную кривую из кусочков.

Вот так выглядят траектории, первая часть у всех одинаковая, середина у кого как, а последняя часть у всех разная (у каждого шарика должно быть свое место в "стакане").

Визуализация траекторий шариков
Визуализация траекторий шариков

Анимация

Анимация в manim делается просто, пример с FadeIn уже был. Вот еще один пример - плавная смена цвета текста, делается буквально в одну строчку. Вообще анимировать можно практически все что угодно, виды анимаций можно посмотреть тут.

class HelloWorld(Scene):
    def construct(self):

        helloWorld = Text("Hello World")
        self.play(helloWorld.animate.set_color(RED), run_time = 3)

Наша анимация посложнее и такой метод не подходит, т.к. надо двигать одновременно много шариков. Каждый по своей траектории, каждый со своим интервалом и еще в строго определенные моменты менять показания счетчиков. Для таких вещей есть пара методов UpdateFromFunc и UpdateFromAlphaFunc. Одним из аргументов туда передается коллбэк функция, которая будет вызываться каждый фрейм, а внутри этой функции уже можно делать что хочется. Функции эти практически ничем не отличаются, UpdateFromAlphaFunc передает параметр alpha, который плавно меняется с 0 в начале анимации до 1 в конце. Нам этого не надо, поэтому используется UpdateFromFunc.

Вот вызов "главной" анимации

        wrapper = VGroup(table, counter)
        for item in items:
            wrapper.add(item.circle)

        runTime = GaltonBoard.config["runTime"]
        self.play(UpdateFromFunc(wrapper, updateFrameFunction), run_time=runTime)

Все достаточно просто, но есть тонкость - чтобы все нормально анимировалось в эту функцию надо передать все объекты, которые будут изменяться. Иногда некоторые простые анимации работали нормально и без этого, просто передавая туда пустышку. Решается все просто, добавляем все нужные объекты в VGroup и все всегда работает. Но на это надо обращать внимание.

Сама функция

updateFrameFunction
        def updateFrameFunction(table):
            durationSeconds = GaltonBoard.config["durationSeconds"]
            durationFrames = durationSeconds * self.camera.frame_rate
            self.frameNumber += 1

            for item in items:
                if item.isActive and self.frameNumber > item.startFrame:
                    alpha = (self.frameNumber - item.startFrame) / durationFrames
                    if (alpha <= 1.0) :
                        point = item.path.point_from_proportion(rate_functions.linear(alpha))
                        item.circle.move_to(point)     
                    else:
                        updateCounter()
                        updateStackValue(item.stackIndex)
                        item.isActive = False

Функция вызывается каждый фрейм, при вызове увеличиваем счетчик фреймов и от этого дальше пляшем. Обратите внимание на следующие строчки, в них вся магия

point = item.path.point_from_proportion(rate_functions.linear(alpha))
item.circle.move_to(point)

Траектория весьма извилиста и нам надо рассчитать для каждого фрейма точку на этой траектории где должен быть шарик. К счастью для этого уже есть готовые методы. Alpha это часть уже пройденного пути, изменяется от 0 до 1, вычисляется на основе счетчика фреймов. Шарик движется равномерно, поэтому используется линейная интерполяция (rate_functions.linear), но есть и другие на любой вкус.

Еще небольшой фрагмент кода - как обновить значение в таблице, stackValueIndex это номер колонки (который есть в классе шарика и рассчитывается заранее), нумерация начинается с единицы (Pascal?).

updateStackValue
        def updateStackValue(stackValueIndex):
            cell = table.get_entries((1, stackValueIndex + 1))
            val = cell.get_value()
            val += 1
            cell.set_value(val)

Собираем все вместе и вот результат

Разные мелочи

В коде многое, но не все задается в конфиге. С главными вещами можно поиграться - число шариков, задержка между шариками и скорость падения. Можно из шестиугольника сделать треугольник или круг, это не параметр в конфиге, но делается просто. Буквально заменой одной одну строку в методе createHexagons.

tmp = RegularPolygon(n=6, radius = hexSize, start_angle = .5)
# uncomment the following lines to get triangles of circles instead of hexagons
#tmp = RegularPolygon(n=3, radius = hexSize)
#tmp = Circle(radius = hexSize)
Треугольники вместо хексов
Треугольники вместо хексов
Круги вместо треугольников
Круги вместо треугольников

Можно, в отличии от реальной доски Гальтона, вместо одинаковой вероятности отскока, сделать разные вероятности. На картинке ниже 1/3 отскок влево и 2/3 вправо.

Перекос в вероятности отскока шарика на препятствии
Перекос в вероятности отскока шарика на препятствии

Заключение

Создавать видео из питоновского кода мне понравилось. Когда-то давно я баловался 3D Studio Max и есть с чем сравнить. Хотя 3D Max больше про моделирование, ролики в нем тоже можно делать. Там был и свой скриптовый язык, но все равно подходы совершенно другие. Что мне понравилось? Как разработчику с многолетним стажем, конечно писать код проще. Работа в привычной IDE, примеры можно просто скопировать как текст и вставить в нужное место своей анимации, тут же что-то поменять прямо в коде. Думаю разработчику будет проще писать код, чем разбираться со специализированным софтом. С другой стороны, верно и обратное, если человек владеет софтом для 3д моделирования и анимаций, писать код будет непривычно, да и сам подход другой. Возможности тоже сильно различаются, manim все-таки вещь специализированная и довольно простая.

Подвожу итог. Если вы разработчик и хотите сделать видео, не обязательно изучать специализированный софт. Можно делать видео из кода используя manim. Надеюсь мой рассказ поможет в этом.

Исходники на гитхабе.

Комментарии (2)


  1. avshkol
    26.06.2024 20:33
    +1

    Если делать импорт всего

    from manim import *

    то потом сложно понимать, откуда взялась та или иная функция или константа. Нужно запоминать.

    import manim as ma  # Так лучше!


    1. piton_nsk Автор
      26.06.2024 20:33

      Спасибо за совет, если буду дальше питонить, то пригодится..