В современном UI помимо удобства есть еще свистелки и гуделки, чтобы зацепить взгляд пользователя. И, зачастую, такой свистелкой являются анимации: нажал кнопку, а у тебя вокруг спец-эффекты, поставил палец вверх, а он красиво подпрыгнул, и так далее.
Большинство таких анимаций, которые вы видите в приложениях практически каждый день, сделаны с помощью Lottie. Например, в Telegram, часть анимированных стикеров и эмодзи сделаны как раз с помощью Lottie.
Почему именно Lottie? Lottie — это JSON-based формат векторной анимации, поэтому он легковесный, легко масштабируется, не требует видеокодеков, хорошо выглядит на экранах с разным DPI.
Проблема с текущей реализацией в Qt
В Qt не так давно (относительно возраста фреймворка) появился модуль с Lottie и соответствующий компонент — LottieAnimation. В целом, компонент закрывает базовые задачи, но у него есть ограничения и некоторые проблемы, которые уже почти семь лет никто не решает. Для моих задач он не подходит по следующим причинам:
нет масштабирования, то есть компонент отображается в размерах, которые изначально указаны в исходном Lottie;
некоторые Lottie файлы (например, те же стикеры в Telegram) не открываются: в stderr можно увидеть ошибку неподдерживаемого элемента.
Базовый скелет компонента
Штош. Ничего не остается как написать свой QML-компонент для отображения Lottie, классический DIY. Полностью мы, конечно же, нырять в спецификацию Lottie не будем, к счастью на просторах github есть готовые библиотеки для работы с Lottie, например, rlottie. Нам же остается это завернуть в удобный QML-компонент с максимальным соответствием API к стандартному LottieAnimation.
Так как мы пишем графический компонент, то будем наследоваться от QQuickItem — это базовый класс для графических элементов в QML. Первым делом добавляем макрос QML_ELEMENT, который автоматически регистрирует наш C++ класс как тип, доступный в QML. Регистрация происходит по имени класса, в моем случае это — AnotherLottie. В старых версиях Qt это делалось с помощью вызова qmlRegisterType<MyClass>(...) в рантайме и было менее надежным.
Далее добавляем нужные свойства для нашего компонента с помощью макроса Q_PROPERTY, стараемся сделать так, чтобы это соответствовало API LottieAnimation для легкого "переезда" на новый компонент. Выглядит это примерно так:
Q_PROPERTY(bool autoPlay READ autoPlay WRITE setAutoPlay NOTIFY autoPlayChanged) Q_PROPERTY(int direction READ direction WRITE setDirection NOTIFY directionChanged) Q_PROPERTY(int endFrame READ endFrame NOTIFY endFrameChanged) Q_PROPERTY(quint64 frameRate READ frameRate NOTIFY frameRateChanged) Q_PROPERTY(int loops READ loops WRITE setLoops NOTIFY loopsChanged) Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged) Q_PROPERTY(int startFrame READ startFrame NOTIFY startFrameChanged) Q_PROPERTY(int status READ status NOTIFY statusChanged)
Добавляем для вышеописанных свойств необходимые переменные, сеттеры/геттеры и enum'ы. Потом подключаемся к сигналам изменения размеров, ссылки на файл и к сигналу готовности компонента и будем вызывать нашу основную функцию: генерация кадров с помощью rlottie. На этом базовый скелет компонента готов.
Рендер lottie-фреймов
Сам рендер Lottie-фреймов мы будем делать в отдельном потоке, чтобы не блокировать основной поток UI на момент подготовки компонента, в этот момент можно установить статус Status::Loading для компонента, чтобы, например, показывать loading-индикатор на UI, или добавить какую-то другую логику.
Для асинхронного вызова воспользуемся QtConcurrent и promise, что достаточно удобно в нашем случае и позволяет не городить всякие связки с QThread и синхронизацией потоков. Просто закидываем нашу функцию в run, а в then обрабатываем результат:
QtConcurrent::run([...](QPromise<LoadResult>& promise) { // Тяжелая операция }).then([...](LoadResult result)) { // Обработка результата });
Где LoadResult это структура, в которой удобно хранить результат операции:
struct LoadResult { quint64 id; /// Task id AnotherLottie::Status status{AnotherLottie::Status::Null}; /// Status of loading result QSize frameSize; /// Animation origin frame size quint64 duration; /// Animation duration in msecs quint64 frameRate; /// Frame rate in msecs QList<QSGTexture*> textures; /// List of rendered textures };
Непосредственно в функции мы будем делать следующее:
пытаться открыть файл, прочитать все его содержимое и инициализировать объект
rlottie::Animation;читать нужные свойства из
rlottie::Animation: исходный размер фрейма, количество кадров, частоту кадров;отрисовывать фрейм в
QSGTexture.
Тут самое интересное то, что мы сразу подготовим текстуры, которые нужны для Quick Scene Graph (QSG) в качестве QSGTexture*. QSGTexture — это абстрактный класс, представляющий из себя текстуру, которая используется GPU при отрисовке QML-сцены. QSG рендерит всё через GPU, и любое изображение (картинка, кадр видео, результат рендера в текстуру) на уровне рендеринга существует именно как QSGTexture. Класс абстрагирует графический API — один и тот же интерфейс работает с OpenGL, Vulkan, Metal или Direct3D, скрывая платформенные детали, такие как нативные хэндлы текстур, форматы, координаты.
Для удобства в Qt есть метод, который генерирует текстуры из QImage: QQuickWindow::createTextureFromImage. QImage будем использовать как буффер для rlottie::Surface:
QList<QSGTexture*> textures; QImage frame{result.frameSize, QImage::Format_ARGB32_Premultiplied}; for (std::size_t index = 0; index < animation->totalFrame(); ++index) { // Surface for rendering rlottie::Surface surface{reinterpret_cast<std::uint32_t*>(frame.bits()), static_cast<std::size_t>(frame.width()), static_cast<std::size_t>(frame.height()), static_cast<std::size_t>(frame.bytesPerLine())}; // Render synchronous current frame animation->renderSync(index, std::move(surface)); // Create texture from image QSGTexture* texture = window->createTextureFromImage(frame); textures << texture; }
Тут есть пара важных моментов.
Во-первых, прежде чем генерировать текстуры, нужно убедиться, что QSG инициализирован и готов к работе, иначе мы получим невалидные (nullptr) текстуры. Проверить это можно с помощью window()->isSceneGraphInitialized().
Во-вторых, наш элемент может быть готов до того, как он будет добавлен на сцену, поэтому лучше всего убедиться, что window() тоже валидный, прежде чем обращаться к его методам. Лучше всего работу с компонентом осуществлять когда он уже готов, используя метод QQuickItem::componentComplete.
Отрисовка на сцене
QSG использует классический подход для отрисовки сцены - render tree. Для каждого визуального элемента на сцене QSG хранит дерево узлов, где каждый узел это наследник QSGNode. В каждом таком узле хранится информация об узле: его геометрия, текстуры, материалы итд.
Чтобы что-то увидеть на сцене нужно переопределить метод QQuickItem::updatePaintNode. Это то самое место, где строится дерево нашего элемента. QSG автоматически вызывает его, на этапе синхронизации, когда элемент помечен "грязным" (dirty).
Как все это устроено:
При первом вызове
oldNode == nullptr— нужно создать узел в нашем случае этоQSGImageNode, создать его удобнее всего воспользовавшись готовой функцией:window()->createImageNode(). При последующих вызовах в функцию передается ранее возвращённый узелoldNode— его мы уже будем обновлять, а не пересоздавать.Результат, который мы вернем из функции (
oldNode) будет передан в нашу функцию в следующий раз. Чтобы узел удалился из дерева сцены, нужно вернутьnullptr.Чтобы метод вообще вызывался, нужно выставить флаг
QQuickItem::setFlag(ItemHasContents, true), а для перерисовки — вызватьupdate().
Учитывая вышеописанное, наша реализация QQuickItem::updatePaintNode будет выглядеть следующим образом:
QSGNode* AnotherLottie::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* updatePaintNodeData) { Q_UNUSED(updatePaintNodeData) // Ensure if index is valid Q_ASSERT_X(mCurrentFrameIndex >= 0 && mCurrentFrameIndex < mTextures.size(), "AnotherLottie::updatePaintNode", "Invalid frame index"); // Create image node if old node is not initialized if (oldNode == nullptr) { oldNode = window()->createImageNode(); Q_ASSERT_X(oldNode, "AnotherLottie::updatePaintNode", "Failed to create scenegraph image node"); } // Cast old node to image node and set texture to a node // NOTE: node does not owns texture QSGImageNode* imageNode = static_cast<QSGImageNode*>(oldNode); imageNode->setTexture(mTextures.at(currentIndex())); imageNode->setOwnsTexture(false); imageNode->setRect(QRectF{0.0, 0.0, width(), height()}); return oldNode; }
Дальше остается только "крутить" по таймеру индекс текущего фрейма, и базовый функционал анимации готов:
void AnotherLottie::handleTimerTimeouted() { ++mCurrentFrameIndex; // Normalize index if exceeded if (mCurrentFrameIndex >= mTextures.size()) { mCurrentFrameIndex = 0; // Stop if loops exceeded if (mLoops > 0) { ++mCurrentLoop; if (mCurrentLoop >= mLoops) { stop(); Q_EMIT finished(); return; } } } update(); }
Замечания
Наши текстуры лежат просто в куче и на руках у нас сырые указатели. Поэтому, в деструкторе, или при обновлении ресурса необходимо позаботиться о том, чтобы корректно удалить эти самые текстуры и освободить память.
Это PoC, и по хорошему асинхронный вызов нужно сделать более аккуратным. Сейчас при изменении размеров у нас асинхронно может выполняться несколько задач. Правильнее было бы держать где-то актуальную ссылку на текущий promise и отменять активную задачу, чтобы не потреблять много ресурсов.
Исходный код можно посмотреть тут.
