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

Original author: Boris Smus - Developer Relations, Google
  • Translation
В последнее время мне везет натыкаться на интересные статьи для перевода. На этот раз – статья на 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. Мешает искать узкие места отрисовки
...
Share post

Similar posts

Comments 42

    +3
    Полезная статья, спасибо!
      0
      Упражняюсь в английском, незачто!
        0
        И в русском поупражняйтесь, не_за_что.
          +5
          Точно!
      0
      Огромное Вам спасибо за вклад в освещение HTML5!
        0
        Хорошо, что это еще кому-нибудь, кроме меня, полезно
        +3
        Гигантское вам спасибо за совет использовать многослойные canvas. Вроде бы и просто, а в голову не пришло.
          0
          Метод довольно эффективен, так как переносит довольно большую часть операций на ОС. Особенный профит от его использования возникает в играх-платформерах, когда весь уровень можно отрисовать в фоновом канвасе и просто двигать его внизу, под основной сценой. То же самое, когда реализуется паралакс-эффект.

          Еще, думается, можно попробовать отдельные спрайты делать канвасами – потому что основная сцена получается очень разреженной.
            +3
            На мобильных устройствах ситуация абсолютно обратная — многослойные канвасы сильно тормозят, приходится использовать 1 + пререндер сцены в бэкграунд (причём бэк div'а размером с канвас и расположенный под ним, поскольку необъяснимым образом background у канваса опять же сильно тормозит).
            Ещё многими хитрыми способами можно добиться вполне нормальной скорости работы канваса на мобильниках.
              0
              Будет круто, если вы напишите о них. Я портировал игру под canvas, нацеливаясь именно на Android и iOS, но проблемы с производительностью даже в браузере развеяли мои мечты и я подзабил. Хотелось бы довести до ума.
                +3
                Всё никак не наберу критическую массу хаков для полноценной статьи.
            +1
            Я как раз использую несколько слоёв при разработке игры на LibCanvas.
            Не надо увлекаться, пары слоёв вполне достаточно. Основной выигрыш дали другие приёмы, несколько слоёв просто немного помогли мне. Кеширование в бэк-буфер, не полная отрисовка канваса (а только изменённые части), и т.п.
              +2
              Прошу прощения, это писал я, с ноута девушки. Забыл перелогиниться.
                0
                Допустим, у нас есть супермарио. Почти весь экран статичен, только несколько спрайтов движутся. Разве будет плохо, если уровень отрисовать в нижнем слое, а поверх него двигать canvas'ы размером со спрайт?
                  +1
                  Я бы лучше избежал лишних дом-операций. Уровень классно отрисовывать в нижнем слое,, согласен, ещё слой для статичных, редко-меняющихся объектов типа разрушающихся стен. а в верхнем слое — оптимизированно отрисовывать маленькие спрайты.
            0
            Ого, огромная работа. Спасибо.
              0
              Тогда от следующего перевода вы в обморок упадете :)
                0
                Энтузиазм на высоте ;)
                  +1
                  Работать и учиться не хочется :)
                    0
                    Особенно работать ;)
                      0
                      Практика показала, что работать хочется больше, чем учиться :(
              +1
              >>К сожалению, на момент написания, только iOS 5.0 beta с Safari 5.1 использовала аппаратное ускорение canvas

              Вроде на обновлении винФона тоже Ие с аппаратным ускорением. Хотя что говорить о телефонах — когда даже на декстопах нормальное ускорение канваса есть только у ИЕ и ФФ
                0
                Меня тоже удивило, что мой хром не использует аппаратное ускорение.
                  +1
                  about:flags
                +2
                Не буду оригинальничать, добавлю два примеров, как легко использовать оптимизации из топика на LibCanvas:

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

                var layer1 = new LibCanvas('canvas');
                layer2 = layer1.createLayer('second');
                layer3 = layer1.createLayer('elseOne');
                
                layer1.ctx.fill( new Rectangle(20,20,50,50) );
                layer3.ctx.fill( new Rectangle(80,80,50,50) );
                


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

                Параметр optimize у drawImage. Оптимизация, кстати, очень хорошая, производительность повысилась значительно.
                ctx.drawImage({
                   image: libcanvas.getImage('test'),
                   from: [15, 25],
                   optimize: true
                });
                
                  0
                  <эстетика головного мозга>createLayer – хм, удобно, но не очень элегантно :) Вы думаете, так лучше, чем какой-нибудь класс Scene и его поле – Layers = [Layer1,Layer2,..] ?</эстетика>
                    0
                    Это разные уровни абстракции ;) Какой-нибудь Scene вполне может использовать эти самые createLayer.
                      0
                      Ну а в целом, createLayer – это такое продвинутое копирование и все?
                        0
                        Нет, это просто создание холста во враппере с абсолютной позицией, как описано в топике. Просто изящный интерфейс и ряд мелких плюшек внутри.
                          0
                          Ну, я это и обозвал продвинутым копированием, но библиотека у вас удобная, да
                            +2
                            Мы сейчас на работе делаем два коммерческих проекта — игры на LibCanvas, осенью будет бета, так что развитие библиотеки гарантированно. Ну и будет что интересненькое показать)
                  0
                  Очень полезно, спасибо!
                    0
                    Был немного удивлен первым триком.
                    Я как-то надеялся что браузер оптимизирует поток рендера хотя бы внутри requestAnimationFrame
                    Остается только построить кореляцию между ускорением использование этого pbufferа, и лишней нагрузкой которую генерит копирование, очень больших вообщето, обьемов данных.
                    И хотелось бы добавить про многослоные канвасы: дети, каждый канвас занимает в памяти как минимум X*Y*3.
                      +1
                      гм, посмотрел на исходник первого теста, понял что кто-то в интерете не прав.
                      Ваш «канвас» ничем от обычной картинки не отличается
                        +1
                        Очевидно, что текст не совпадает с примером и это вина не переводчика, в оригинале тоже непонятно.
                        Судя по коду имеется ввиду, что вместо того, чтобы каждый кадр рисовать графические примитивы — лучше их отрисовать один раз в буфер, а потом отрисовывать из буфера и это правда — отрисовать круг согласно математическим расчётам, которые производятся в .arc или кривую, согласно математическим расчётам, которые производятся в bezierCurveTo значительно медленнее, чем просто скопировать картинку соответствующего размера.

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

                        Каждый канвас занимает не X*Y*3, а X*Y*4, вы забыли про альфа-канал, но это не так важно на практике.
                        Если у нас игра размером, скажем, 1680*1050 (размер — огромен, обычно казуалки намного меньше), то это 7 метров на слой. Даже 5 слоёв — это 35 метров оперативки на игру, не так много ;)
                          0
                          Честно говоря, не могу попасть в контекст ваших двух комментариев. Но если у вас есть конкретные предложения для правок – внимательно слушаю.
                            +1
                            Вы не виноваты, точно такой же текст и в оригинале. Просто название раздела «Предварительно отрисовывайте в виртуальный canvas» немного сбиваешь с мысли и начинаешь думать не о «кешировании векторных объектов в растре», а о «двойной буферизации»
                              0
                              А, наконец-то понял. И все к тому, что целиком растровые сцены нет реального смысла буфферизировать?
                      +2
                      Как-то в статье не заметил, но хотя бы в абзац про нецелые координаты стоило бы добавить, что координатная сетка не целочисленная и начинается с (0.5; 0.5), а целые координаты — границы пикселей.
                      В интернетах это освещено более подробно, а на сайте MDN можно посмотреть пример (линии нечётной ширины выглядят шире, как-будто «размытыми»).
                        +1
                        На самом деле это не совсем так. Ведь когда вы делаете fillRect — ничего не блурится ;) И линия шириной 2 пикселя — тоже не блурится. Просто особенности рендеринга и на производительность они мало влияют.

                    Only users with full accounts can post comments. Log in, please.