Оптимизация работы элемента canvas в HTML5

HTML5 Rocks

Введение

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

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

Эта статья не освещает вопрос использования элемента canvas в языке HTML5. Сведения на эту тему можно найти в статьях об элементе canvas на ресурсе HTML5Rocks, в одном из разделов "Погружения в HTML5" и в руководстве MDN Canvas.

Испытание эффективности

Поскольку ситуация с элементом canvas в языке HTML5 быстро меняется, тесты JSPerf (их можно найти на сайте jsperf.com) позволяют проверить эффективность каждого из предложенных методов оптимизации. JSPerf – это веб-приложение, которое позволяет разработчикам создавать тесты для проверки JavaScript-кода на эффективность. Каждое такое испытание ориентировано на результат, которого необходимо достичь (например, очистка элемента canvas), и реализует несколько подходов для его достижения. JSPerf выполняет каждый из алгоритмов максимально возможное количество раз за короткий промежуток времени и возвращает статистически значимое число итераций в секунду. Чем выше полученный балл, тем лучше.

Посетители страницы JSPerf могут запустить тест в своем браузере, а система JSPerf сохранит его нормализованные результаты на сайте Browserscope (browserscope.org). Поскольку методы оптимизации, описанные в этой статье, основаны на результатах JSPerf, вы можете периодически возвращаться к ней за обновленной информацией об актуальности каждого из них. В статью встроено небольшое вспомогательное приложение, которое выводит результаты в виде графиков.

Все показатели эффективности в этой статье зависят от версии браузера. В этом заключается проблема, поскольку нам неизвестно, в какой ОС был запущен браузер, и, что еще более важно, использовалось ли для элемента canvas в языке HTML5 аппаратное ускорение в момент запуска теста эффективности. Чтобы узнать, был ли элемент canvas языка HTML5 в браузере Chrome аппаратно ускорен, введите в адресной строке about:gpu.

Предварительный рендеринг во внеэкранный элемент canvas

Если вы многократно рисуете одинаковые простейшие элементы в нескольких фреймах на экране (например, в игре), предварительный рендеринг крупных фрагментов сцены позволяет существенно повысить эффективность кода. Предварительный рендеринг – это вывод временных изображений на отдельном внеэкранном элементе canvas (или на нескольких таких элементах) с последующим переносом этих внеэкранных элементов на видимый объект canvas. Для тех, кто знаком с компьютерной графикой, этот метод также известен как список отображения.

Например, представим, что вы перерисовываете персонажа Марио, перемещающегося со скоростью 60 кадров в секунду. Можно либо перерисовывать в каждом кадре его кепку, усы и букву "М", либо выполнить предварительный рендеринг персонажа перед запуском анимации.

Вот вариант без предварительного рендеринга:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

А вот вариант с предварительным рендерингом:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext(‘2d’);
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

Обратите внимание на использование API requestAnimationFrame, который будет подробно описан в одном из следующих разделов. Приведенный ниже график иллюстрирует повышение эффективности при использовании предварительного рендеринга (см. этот тест jsperf).

Этот метод особенно эффективен, если операция рендеринга (drawMario в приведенном выше примере) является дорогой. Хороший пример – рендеринг текста: это очень дорогая операция. Ниже продемонстрировано резкое повышение эффективности в результате ее предварительного рендеринга (см. этот тест jsperf).

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

can2.width = 100;
can2.height = 40;

Сравните его с большим элементом, эффективность которого ниже.

can3.width = 300;
can3.height = 100;

Группировка вызовов canvas

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

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

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

Сравните его с более эффективным кодом, в котором рисуется одна ломаная линия.

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

То же касается и элементов canvas в языке HTML5. Например, рисуя сложный маршрут, лучше разместить все точки на одном пути, чем обрабатывать сегменты по отдельности (см. этот тест jsperf).

Однако с элементом canvas в этом правиле связано важное исключение: если примитивы, из которых состоит объект, ограничены небольшими прямоугольниками (например, горизонтальными и вертикальными линиями), возможно, эффективнее обрабатывать их по отдельности (см. этот тест jsperf):

Отказ от ненужных изменений состояния canvas

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

Если, например, для рендеринга сцены используются различные цвета заливки, дешевле выполнять рендеринг по цвету, чем по размещению на элементе canvas. Чтобы обработать рисунок в полоску, можно обработать полосу, изменить цвета, обработать следующую полосу и т. д., как показано ниже.

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

Сравните этот подход с последовательной обработкой всех нечетных полос, а затем – всех четных.

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

Ниже приведен тест эффективности рисования чередующихся полос с помощью обоих методов (см. этот тест jsperf).

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

Рендеринг только разницы, а не нового состояния целиком

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

context.fillRect(0, 0, canvas.width, canvas.height);

Сравните его с кодом, в котором отслеживается и очищается ограничивающий прямоугольник нарисованного фрагмента.

context.fillRect(last.x, last.y, last.width, last.height);

Этот подход проиллюстрирован в приведенном ниже тесте эффективности, в котором белая точка пересекает экран (см. этот тест jsperf).

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

Этот метод также применим к попиксельному рендерингу, что продемонстрировано в этом обсуждении эмулятора Nintendo на JavaScript.

Использование многослойных элементов canvas для сложных сцен

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

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

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

Часто можно воспользоваться несовершенством человеческого восприятия, обрабатывая фон только один раз или просто реже, чем верхний слой (на котором, как правило, сосредоточено внимание пользователя). Например, можно выводить верхний слой каждый раз при рендеринге, а фон – только для каждого n-ного фрейма.

Также обратите внимание на то, что такой подход применим к любому количеству составных элементов canvas, если приложение лучше работает с такой структурой.

Отказ от shadowBlur

Как и многие другие графические среды, элемент canvas в языке HTML5 позволяет разработчикам размывать примитивные объекты, но эта операция может быть очень дорогой.

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

В тесте ниже показана одна сцена, обработанная с тенью и без нее, с огромной разницей в эффективности (см. этот тест jsperf).

Использование разных способов очистки элемента canvas

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

Как уже упоминалось в разделе Отказ от ненужных изменений состояния canvas, очищать весь элемент canvas часто нежелательно, однако если это неизбежно, сделать это можно двумя способами: с помощью метода context.clearRect(0, 0, width, height) или специальной операции canvas.width = canvas.width;.

На момент написания этой статьи метод clearRect более эффективен, чем сброс ширины, однако иногда в браузере Chrome 14 операция с canvas.width работает намного быстрее (см. этот тест jsperf).

Следуйте этой рекомендации с осторожностью, поскольку ее эффективность во многом зависит от реализации элемента canvas. Дополнительные сведения можно найти в статье Саймона Сарриса (Simon Sarris) об очистке элемента canvas.

Отказ от координат с плавающей запятой

Элемент canvas в языке HTML5 поддерживает субпиксельный рендеринг, отключить который нельзя. При рисовании с дробными координатами для выравнивания линий автоматически применяется сглаживание. Ниже показан визуальный эффект, взятый из этой статьи Себ Ли-Делисл (Seb Lee-Delisle) о субпиксельной эффективности элемента canvas.

Субпиксельный

Если эффект сглаживания спрайтов не нужен, эффективнее будет преобразовать координаты в целые числа с помощью операции Math.floor или Math.round (см. этот тест jsperf).

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

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

Эффективность этих методов демонстрируется в примере ниже (см. этот тест jsperf).

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

Оптимизация анимации с помощью requestAnimationFrame

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

API requestAnimationFrame позволяет добиться скорости, близкой к 60 кадрам в секунду, но не гарантирует ее, поэтому необходимо следить за временем, прошедшим с последней операции рендеринга. Это может выглядеть приблизительно так, как показано ниже.

var x = 100;
var y = 100;
var lastRender = new Date();
function render() {
  var delta = new Date() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

Помните, что такая техника использования requestAnimationFrame относится не только к элементу canvas, но и к другим технологиям рендеринга, таким как WebGL.

На момент написания статьи этот API доступен только в браузерах Chrome, Safari и Firefox, поэтому используйте одну из этих оболочек.

Большинство мобильных реализаций canvas медленные

Рассмотрим мобильные платформы. К сожалению, на момент написания этой статьи мобильная реализация canvas с ускорением на базе графического процессора есть только в браузере Safari 5.1 в бета-версии iOS 5.0. Без такого ускорения вычислительной мощности мобильных браузеров, как правило, недостаточно для современных приложений на основе canvas. В ряде описанных выше тестов JSPerf эффективность на мобильных устройствах на порядок ниже, чем на компьютерах, что существенно ограничивает число программ, способных успешно работать на разных платформах.

Заключение

В этой статье перечислен целый набор полезных методик оптимизации, которые помогут в разработке эффективных проектов HTML5 с использованием элемента canvas. Они позволят вам улучшить свой код. Если вам только предстоит написать его, загляните на сайты Chrome Experiments и Creative JS.

Ссылки

Comments

0