MnCreator: рисуем сотни объектов за 1 миллисекунду
Небольшое отступление
На протяжении многих лет я слышу, что стек HTML5/CSS3/JS очень медленный, плохой, неудобный и вообще, должен умереть. И отчасти эти люди правы - и "спасибо" за это нужно сказать JQuery и всем тем, кто думает что можно налепить код низкого качества и ожидать что умный webkit всё сделает за тебя: там ведь и Garbage Collector есть, и какая-то новая умная JIT-компиляция, и разработчики постоянно что-то оптимизируют да оптимизируют.
Так вот, надеюсь хоть у кого-то процента людей после прочтения этой статьи придет понимание, что какая бы крутая и автоматизированная технология ни была, она не сможет сделать чудо. В отличие от разработчика.
Сразу хочу сказать что я не теоретик, а практик, и поэтому многое сказанной мной ниже в отношении теории может быть сказано более правильно и понятно экспертами.
Исходные данные
Итак, у нас есть приложение "ДубДом" для детей, про обиталетей леса. На 2х сценах находится по 400 объектов и очень много разных анимаций. Выбираем одну, измеряем количество объектов и анимаций, и считаем сколько времени уходит на отрисовку:
- 297 объектов
- 136 анимаций, работающих постоянно и одновременно.
Тестовый стенд: Intel Core i7 860 (2009го года), 8гб памяти (оттуда же), nVidia GTX 460Ti (2011й), Linux x64 (Kubuntu 14.10), Chrome 40.
Скриншоты отладчика:
Из показанных скриншотов важна следующая информация:
- Желтая область на нижнем скриншоте - показывает сколько времени уходит на выполнение непосредственно нашего кода, то есть сейчас это 14.229мс на кадр.
- Фиолетовая область на нижнем скриншоте - показывает сколько времени уходит на то, чтобы произведенные нами изменения в CSS "применились". Сейчас это 4.895мс за кадр.
- Рост потребления памяти (HEAP) на синем графике на верхнем скриншоте за кадр составляет 400кб, а GC вызывается раз в 200мс, то есть реально очень часто.
- Столбцы на обоих скриншотах почти полностью забиты желтыми областями (и немного рендеринга), то есть почти всё время webkit занимается выполнением моего кода. Так же видно, что они выходят за линию в 60fps.
- Зеленая область не особо важна, потому что, насколько я понял, она работает как "бездействие" в диспетчере задач Windows - сожрет столько, сколько есть, и повлиять на неё напрямую не получится (она будет расти за счет уменьшения scripting'а и rendering'а), но чем она больше - тем больше времени у устройства на реальную отрисовку.
Как устроен главный цикл?
Условно, сначала мы проходим по каждому объекту на сцене и вызываем метод calculate, который "применяет" все происходящие анимации за прошедший с последней отрисовки период, а потом на каждом объекте вызываем метод draw, который транслирует получившиеся результаты в CSS. У объекта есть следующие параметры, которыми пользователь может управлять и создавать для них анимации:
- Положение по осям X,Y
- Масштабирование по осям X,Y
- Угол поворота
- Углы наклона по осям X,Y
- Положение оси вращения по X,Y (это та точка, которая остается на месте при вращении или масштабировании объекта)
- Прозрачность
- Уровень слоя (zIndex)
Все эти параметры условно разбиты на 4 группы по тому, в какое CSS-свойство они пишутся:
1. transform - положение, масштаб, поворот, наклон
2. opacity - только прозрачность
3. zIndex - только уровень слоя
4. transformOrigin - только положение оси вращения
И всё, что делает метод draw - берет и записывает уже посчитанные значения в соответствующие css. Давайте думать, как оптимизировать всё это дело.
Оптимизация
Шаг 1 - Матрицы
Свойство CSS transform может принимать как человечески-понятную строку вида "translate3d(10px,10px,0px) scale3d(1,2,1) rotateZ(90deg) skewX(0deg) skewY(0deg)", так и некую матрицу 4x4, которая получилась из хитрых преобразований параметров объекта (почитать про это подробнее можно где угодно: 1, 2, 3). JsPerf.com (на данный момент он мертв, но, поверьте мне, пожалуйста, на слово, тем более чуть позже это станет видно) говорит нам, что матрица работает быстрее. Что же, давайте проверим.
Отступление: голый расчет матрицы преобразования - это несколько перемножений толпы матриц 4x4, в каждую из которых засунуты определенные параметры объекта в определенные ячейки. Это крайне прожорливый процесс. Поэтому была применена предварительная оптимизация: были выведены выражения для каждой ячейки финальной матрицы для некоторых абстрактных translate_x,translate_y, scale_x, scale_y, rotate_z, skew_x, skew_y. И вместо постоянного создания и умножения матриц, сразу рассчитывается финальная матрица. При этом кстати, отпала куча умножений сложных выражений с параметрами объекта на 0.
Неплохо, неправда ли?
- Желтая область сократилась до 10.724мс(-25%). Заметьте, произвести несколько вычислений и засунуть результат в css-свойство стало быстрее, чем ничего не считать и засунуть результат в css-свойство немного по-другому.
- Рендеринг стал 3.476мс(-29%). Опять же, формально css не поменялся, просто matrix, видимо, можно реально быстрее обработать.
- Рост HEAP за кадр - около 300кб (-25%), а GC стал вызываться раз в 580мс (не 5 раз за секунду, а меньше 2). Обусловлено, вероятно, тем, что меньше работ со строками (для трансляции матрицы используется .join(','), а раньше - простая конкатенация).
- По столбцам уже лучше - начали появляться зеленые области, в которых (формально) webkit в отношении моего кода бездействует. Столбцы всё равно выходят за 60fps, но уже меньше и не реальной работой, а белыми областями idle.
Хорошо, но не достаточно. Думаем дальше.
Шаг 2 - Флаги отрисовки
Давайте внимательно посмотрим на метод draw у объекта: он на каждый кадр присваивает все css заново. Казалось бы, ну и что, это же просто строка, а браузерный рендер уже разберется, увидит что ничего не изменилось и не будет ничего делать. Но всё равно, зачем выполнять эти лишние действия, если эти параметы не менялись? Формально, движок занимается только воспроизведением, и на этапе его запуска мы уже четко знаем у каких объектов какие анимации есть, и новых (пока) появиться просто не может.
Так что я немного подшаманил, и каждый объект стал иметь 2 типа флагов:
- Статический флаг - означает какие вообще у объекта есть анимации. Поскольку мало какие объекты реально одновременно перемещаются, масштабируются, вращаются и наклоняются, этот флаг позволяет ещё больше упростить функцию вычисления матрицы преобразования (подставив константы). Рассчитывается конструктором на этапе экспорта в движок, а движке уже выбирается нужный вариант расчета матрицы.
- Динамический флаг - означает какие именно изменения произошли с объектом за 1 такт вычисления анимаций. Позволяет не трогать не изменившиеся css-свойства. Пришлось немного усложнить этап рассчета анимаций, чтобы движок записывал в флаг какие изменения он произвел, но оно того стоило.
Поглядим на результаты:
Я перепроверял несколько раз. Это действительно правильные результаты.
- Желтая область сократилась до 0.766мс (-93%). Я не знаю, что именно так сильно повлияло на результат, но подозреваю что это zIndex, поскольку при его изменении webkit'у нужно менять порядок отрисовки слоев, а пересортировка нескольких сотен объектов - не так-то и быстро.
- Рендеринг - 0.395мс (-88%). Опять же, похоже что "быстрее" не трогать свойство вообще, чем трогать его на то же самое значение.
- Рост HEAP за кадр - около 35кб (-88%). GC стал вызываться настолько редко, что мне просто не удалось его поймать: при попытке записать больше 10 секунд мой компьютер наглухо виснет (поскольку подобный анализ жрет очень много памяти), но на меньшем количестве времени он просто не вызывался.
- Дальнейшие комментарии по столбцам просто излишни :) Совсем иногда они вылезают за 60fps, но на этапе painting'а.
В принципе, здесь можно было бы остановиться, но кого устраивают полумеры? :)
Шаг 3 - Шлифуем статическими объектами
Итак, теперь из всех объектов у нас происходит перерисовка только нужных свойств. И внезапно стало понятно, что многие объекты не анимируются вообще. А именно - 278 из 297 (то есть анимируются только 19 объектов, и на них действует 136 разных анимаций). Так может быть, просто "забыть" об этих объектах? Создали, нарисовали, и всё?
Сказано - сделано, было произведено ещё 2 оптимизации:
- Если конструктор выяснил, что с объектом не происходит вообще ничего, он помечается специальным флагом. Движок при создании этого объекта проверяет флаг, рисует объект 1 раз и забывает о нём. Больше этот объект не участвует ни в каких циклах.
- Если объект всё-таки анимируется, то проверка на флаги анимаций была вынесена из функции отрисовки (draw) чуть выше - прямо перед её вызовом. Не буду вдаваться в детали, но гораздо дешевле проверить простое условие не попадая в функцию, чем проверять её внутри и делать return: не производится вызов, не создается отдельный scope, аргументы, и так далее.
Результат:
- Желтая область - 0.516мс (-32%). Циклы по объектами сократились почти в 15 раз - вместо обхода всех 300 объектов теперь обходится лишь 20.
- Рендеринг - 0.415мс (+5%). Здесь можно списать на погрешность, потому что речь идет о величинах, которые меньше 1/1000 секунды.
- Рост HEAP за кадр - около 30кб (-15%). Я же говорил, что проверять условие перед вызовом функции гораздо дешевле. При этом, если кто-то не видит большой разницы между 30 и 35 кб, я напомню, что это 5 кб за 1 кадр, которых в секунде - 60. То есть это на 300кб ниже за секунду.
Итоги
На этом я остановился. Прирост скорости - почти в 20 раз:
- Желтая область - 0.516мс вместо 14.229мс (в 27 раз быстрее).
- Рендеринг - 0.415мс вместо 4.895мс (в 11 раз быстрее).
- Рост HEAP - 30кб вместо 400кб (в 13 раз меньше).
- Заветные 60fps достигнуты с огромным запасом производительности.
Вывод
JavaScript может, и ещё как может!
Думаю, что когда авторы приложений на MnCreator'е дойдут не до сотен, а до тысяч объектов на одной сцене, я вернусь к этому вопросу и добьюсь новых высот. Ну а пока вы можете оценить текущие, приняв участие в бета-тесте конструктора.
Спасибо за внимание и что дочитали статью до конца! :)