Главное Авторские колонки Вакансии Образование
483 2 В избр. Сохранено
Авторизуйтесь
Вход с паролем

MnCreator: рисуем сотни объектов за 1 миллисекунду

Проблема: приложение ДубДом студии ДругЗаДруга на некоторых сценах не выдает заветные 60fps (всего 40-45). Уменьшаем время отрисовки кадра с 20мс до 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.

Скриншоты отладчика:

Из показанных скриншотов важна следующая информация:

  1. Желтая область на нижнем скриншоте - показывает сколько времени уходит на выполнение непосредственно нашего кода, то есть сейчас это 14.229мс на кадр.
  2. Фиолетовая область на нижнем скриншоте - показывает сколько времени уходит на то, чтобы произведенные нами изменения в CSS "применились". Сейчас это 4.895мс за кадр.
  3. Рост потребления памяти (HEAP) на синем графике на верхнем скриншоте за кадр составляет 400кб, а GC вызывается раз в 200мс, то есть реально очень часто.
  4. Столбцы на обоих скриншотах почти полностью забиты желтыми областями (и немного рендеринга), то есть почти всё время webkit занимается выполнением моего кода. Так же видно, что они выходят за линию в 60fps.
  5. Зеленая область не особо важна, потому что, насколько я понял, она работает как "бездействие" в диспетчере задач Windows - сожрет столько, сколько есть, и повлиять на неё напрямую не получится (она будет расти за счет уменьшения scripting'а и rendering'а), но чем она больше - тем больше времени у устройства на реальную отрисовку.

Как устроен главный цикл?

Условно, сначала мы проходим по каждому объекту на сцене и вызываем метод calculate, который "применяет" все происходящие анимации за прошедший с последней отрисовки период, а потом на каждом объекте вызываем метод draw, который транслирует получившиеся результаты в CSS. У объекта есть следующие параметры, которыми пользователь может управлять и создавать для них анимации:

  1. Положение по осям X,Y
  2. Масштабирование по осям X,Y
  3. Угол поворота
  4. Углы наклона по осям X,Y
  5. Положение оси вращения по X,Y (это та точка, которая остается на месте при вращении или масштабировании объекта)
  6. Прозрачность
  7. Уровень слоя (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.

Неплохо, неправда ли?

  1. Желтая область сократилась до 10.724мс(-25%). Заметьте, произвести несколько вычислений и засунуть результат в css-свойство стало быстрее, чем ничего не считать и засунуть результат в css-свойство немного по-другому.
  2. Рендеринг стал 3.476мс(-29%). Опять же, формально css не поменялся, просто matrix, видимо, можно реально быстрее обработать.
  3. Рост HEAP за кадр - около 300кб (-25%), а GC стал вызываться раз в 580мс (не 5 раз за секунду, а меньше 2). Обусловлено, вероятно, тем, что меньше работ со строками (для трансляции матрицы используется .join(','), а раньше - простая конкатенация).
  4. По столбцам уже лучше - начали появляться зеленые области, в которых (формально) webkit в отношении моего кода бездействует. Столбцы всё равно выходят за 60fps, но уже меньше и не реальной работой, а белыми областями idle.

Хорошо, но не достаточно. Думаем дальше.

Шаг 2 - Флаги отрисовки

Давайте внимательно посмотрим на метод draw у объекта: он на каждый кадр присваивает все css заново. Казалось бы, ну и что, это же просто строка, а браузерный рендер уже разберется, увидит что ничего не изменилось и не будет ничего делать. Но всё равно, зачем выполнять эти лишние действия, если эти параметы не менялись? Формально, движок занимается только воспроизведением, и на этапе его запуска мы уже четко знаем у каких объектов какие анимации есть, и новых (пока) появиться просто не может.

Так что я немного подшаманил, и каждый объект стал иметь 2 типа флагов:

  1. Статический флаг - означает какие вообще у объекта есть анимации. Поскольку мало какие объекты реально одновременно перемещаются, масштабируются, вращаются и наклоняются, этот флаг позволяет ещё больше упростить функцию вычисления матрицы преобразования (подставив константы). Рассчитывается конструктором на этапе экспорта в движок, а движке уже выбирается нужный вариант расчета матрицы.
  2. Динамический флаг - означает какие именно изменения произошли с объектом за 1 такт вычисления анимаций. Позволяет не трогать не изменившиеся css-свойства. Пришлось немного усложнить этап рассчета анимаций, чтобы движок записывал в флаг какие изменения он произвел, но оно того стоило.

Поглядим на результаты:

Я перепроверял несколько раз. Это действительно правильные результаты.

  1. Желтая область сократилась до 0.766мс (-93%). Я не знаю, что именно так сильно повлияло на результат, но подозреваю что это zIndex, поскольку при его изменении webkit'у нужно менять порядок отрисовки слоев, а пересортировка нескольких сотен объектов - не так-то и быстро.
  2. Рендеринг - 0.395мс (-88%). Опять же, похоже что "быстрее" не трогать свойство вообще, чем трогать его на то же самое значение.
  3. Рост HEAP за кадр - около 35кб (-88%). GC стал вызываться настолько редко, что мне просто не удалось его поймать: при попытке записать больше 10 секунд мой компьютер наглухо виснет (поскольку подобный анализ жрет очень много памяти), но на меньшем количестве времени он просто не вызывался.
  4. Дальнейшие комментарии по столбцам просто излишни :) Совсем иногда они вылезают за 60fps, но на этапе painting'а.

В принципе, здесь можно было бы остановиться, но кого устраивают полумеры? :)

Шаг 3 - Шлифуем статическими объектами

Итак, теперь из всех объектов у нас происходит перерисовка только нужных свойств. И внезапно стало понятно, что многие объекты не анимируются вообще. А именно - 278 из 297 (то есть анимируются только 19 объектов, и на них действует 136 разных анимаций). Так может быть, просто "забыть" об этих объектах? Создали, нарисовали, и всё?

Сказано - сделано, было произведено ещё 2 оптимизации:

  1. Если конструктор выяснил, что с объектом не происходит вообще ничего, он помечается специальным флагом. Движок при создании этого объекта проверяет флаг, рисует объект 1 раз и забывает о нём. Больше этот объект не участвует ни в каких циклах.
  2. Если объект всё-таки анимируется, то проверка на флаги анимаций была вынесена из функции отрисовки (draw) чуть выше - прямо перед её вызовом. Не буду вдаваться в детали, но гораздо дешевле проверить простое условие не попадая в функцию, чем проверять её внутри и делать return: не производится вызов, не создается отдельный scope, аргументы, и так далее.

Результат:

  1. Желтая область - 0.516мс (-32%). Циклы по объектами сократились почти в 15 раз - вместо обхода всех 300 объектов теперь обходится лишь 20.
  2. Рендеринг - 0.415мс (+5%). Здесь можно списать на погрешность, потому что речь идет о величинах, которые меньше 1/1000 секунды.
  3. Рост HEAP за кадр - около 30кб (-15%). Я же говорил, что проверять условие перед вызовом функции гораздо дешевле. При этом, если кто-то не видит большой разницы между 30 и 35 кб, я напомню, что это 5 кб за 1 кадр, которых в секунде - 60. То есть это на 300кб ниже за секунду.

Итоги

На этом я остановился. Прирост скорости - почти в 20 раз:

  1. Желтая область - 0.516мс вместо 14.229мс (в 27 раз быстрее).
  2. Рендеринг - 0.415мс вместо 4.895мс (в 11 раз быстрее).
  3. Рост HEAP - 30кб вместо 400кб (в 13 раз меньше).
  4. Заветные 60fps достигнуты с огромным запасом производительности.

Вывод

JavaScript может, и ещё как может!

Думаю, что когда авторы приложений на MnCreator'е дойдут не до сотен, а до тысяч объектов на одной сцене, я вернусь к этому вопросу и добьюсь новых высот. Ну а пока вы можете оценить текущие, приняв участие в бета-тесте конструктора.

Спасибо за внимание и что дочитали статью до конца! :)

+1
В избр. Сохранено
Авторизуйтесь
Вход с паролем