Pull to refresh

Улучшаем производительность HTML5 canvas

Reading time 10 min
Views 38K
Original author: Boris Smus - Developer Relations, Google
В последнее время мне везет натыкаться на интересные статьи для перевода. На этот раз – статья на HTML5Rocks о производительности HTML5 canvas. Автор пишет о некоей стене, в которую упираются разработчики при создании приложений. Какое-то время назад в нее уперся и я при портировании старой-доброй игры на canvas.

К сожалению, графики в оригинале вставлены через iframe. Я мог бы сделать снимки и разместить их изображения, но сам автор позиционирует графики актуальными и такими, которые будут обновляться, потому я просто разместил на них ссылки. Приятного чтения!


image
  1. Вступление
  2. Тестирование производительности
  3. Предварительно отрисовывайте в виртуальный canvas
  4. Группируйте вызовы
  5. Избегайте ненужных изменений состояния
  6. Отрисовывайте только разницу, а не весь холст
  7. Используйте многослойных canvas для сложных сцен
  8. Избегайте shadowBlur
  9. Различные способы очистить экран
  10. Избегайте нецелых координат
  11. Оптимизируйте анимации с помощью 'requestAnimationFrame'
  12. Большинство мобильных реализаций canvas – медленные
  13. Заключение
  14. Ссылки



Вступление


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

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

Имейте в виду, эта статья не является учебником по HTML5 canvas. Но вы можете изучить соответствующие статьи на HTML5Rocks, вот эту главу на Dive into HTML5 и уроки на MDN.


Тестирование производительности


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

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

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


Предварительно отрисовывайте в виртуальный canvas


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

Например, представим, что вы перерисовываете Марио со скоростью 60 кадров в секунду. Вы можете отрисовывать его шляпу, усы и «M» в каждом кадре или предварительно отрисовать его перед запуском анимации.

без pre-rendering:
// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

pre-rendering:
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);
}


Обратите внимание на requestAnimationFrame, использование которой будет описано детальнее немного позднее. Следующий график демонстирует пользу использования pre-rendering (jsperf): график.

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

Как бы то ни было, вы можете наблюдать в примере выше низкую производительность теста “pre-rendered loose”. При предварительной отрисовке важно убедиться, что ваш временный холст имеет «обтягивающий» размер для вашего изображения, иначе выигрыш в производительности встретится с потерей производительности при копировании одного большого холста в другой (которая выглядит, как функция от размера холста-цели). Подходящий холст для примера выше – меньше:
can2.width = 100;
can2.height = 40;

В сравнении с большим:
can3.width = 300;
can3.height = 100;



Группируйте вызовы


Так как отрисовка – дорогая операция, гораздо эффективнее загружать drawing state machine длинными списками команд, а потом выгружать их в видео буффер.

Например, при отрисовке множества линий, гораздо лучше сделать один путь со всеми линиями и нарисовать его в один вызов. Другими словами, чем рисовать отдельные линии:
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. Рисуя сложный path, например, лучше сразу разместить на нем все точки, чем отрисовывать сегменты отдельно (jsperf): график.

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


Избегайте ненужных изменений состояния


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);


Следите за bounding box отрисовываемого и очищайте только ее.
context.fillRect(last.x, last.y, last.width, last.height);


Это показано в следующем тесте, который включает белую точку, пересекающую экран (jsperf): график.

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

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


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


Как говорилось ранее, отрисовка больших изображений обходится дорого и ее стоит избегать. В дополнение к использованию внеэкранного буффера (секция предварительной отрисовки), мы можем использовать холсты, наложенные друг на друга. Используя прозрачность верхнего слоя, мы можем положиться на GPU для применения альфа-канала во время отрисовки. Вы использовать это с двумя абсолютно позиционированными холстами друг на другом, как здесь:
<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>


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

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

Этот способ также применим и к любому другому количеству слоев, если ваше приложение работает лучше с такой структурой.


Избегайте shadowBlur


Как и другие графические среды, HTML5 canvas позволяет разработчикам размывать примитивы, но эта операция очень дорого обходится:
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 – парадигма непосредственного режима графики, сцена должна быть перерисована в каждом кадре. Из-за этого, очиста холста – операция фундаментальной важности в приложений и игр.

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

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

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


Избегайте нецелых координат


HTML5 canvas поддерживает суб-пиксельный рендеринг и нет никакой возможности его отключить. Если вы рисуете с нецелыми координатами, он автоматически использует анти-алиасинг, чтобы сгладить линии. Вот визуальный эффект суб-пиксельной производительности из статьи Seb Lee-Delisle:
bunny

Если сглаженный спрайт – это не то, что вам нужно, намного быстрее будет переводить ваши координаты, используя 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 станут GPU-ускорены, что позволит быстро отрисовывать нецелые координаты.


Оптимизируйте анимации с помощью `requestAnimationFrame`


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

Вызов requestAnimationFrame нацелен на 60 FPS, но не гарантирует его, так что вы должны следить, сколько времени прошло с последней отрисовки. Это может выглядеть так:
 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 – медленные


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


Заключение


Эта статья покрыла обширный набор полезных оптимизаций, которые помогут вам разрабатывать производительные HTML5 canvas-приложения. Теперь, когда вы узнали что-то новое здесь, дерзайте и оптимизируйте свои невероятные творения. Или, если у вас еще нет игры или приложения для оптимизации, посмотрите Chrome Experiments или Creative JS для вдохновления.


Ссылки




Избранные комментарии


mikenerevarin #
На мобильных устройствах ситуация абсолютно обратная — многослойные канвасы сильно тормозят, приходится использовать 1 + пререндер сцены в бэкграунд (причём бэк div'а размером с канвас и расположенный под ним, поскольку необъяснимым образом background у канваса опять же сильно тормозит).
Ещё многими хитрыми способами можно добиться вполне нормальной скорости работы канваса на мобильниках.

TheShock #

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

А вот реальная «двойная буферизация» особого смысла не имеет — ведь браузер имеет свой собственный бек-буфер, а отрисовка бек-буфера на основной слой:
1. Занимает слишком много времени
2. Мешает искать узкие места отрисовки
...
Tags:
Hubs:
+110
Comments 42
Comments Comments 42

Articles