Введение
Не так давно помогал брату сделать проект для курсовой. Необходимо было создать клиент - серверное приложение, и решено было создать небольшую браузерную игру с мультиплеером. Курсовая была сдана успешно, а у меня появилось желание сравнить различные возможные методы отрисовки изображений HTML5 Canvas
, с целью найти оптимальные решения. Моё исследование было проведено из любопытства и не предлагает чего-то революционного, однако информация в статье может быть полезна, или, в крайнем случае, интересна.
Способ тестирования
Для определения разницы в отрисовке возьмем картинку формата PNG и будем разными способами отрисовывать её на канвасе. Каждым способом выведем эту картинку в количестве от 27 (128) до 220 (1'048'576), с шагом равным степени двойки, за раз и сравним время, которое моему компу пришлось обрабатывать этот рендер. Стоит упомянуть, что для замера времени выполнения каждую итерацию тестирования функции рендера будем проводит по 10 раз для получения среднего результата, так как встроенные функции измерения длительности выполнения будут давать результат с погрешностью около 2ms (это погрешность специально зашита в браузер с целью защиты пользователей).
Проведем подготовительные работы: Создадим страницу с canvas, установим разрешение области отрисовки на 800x450px, чтобы получить, для красоты, соотношение строн 16:9. Соответственно установим и размеры элемента на странице (также для красоты). Получим 2d - контекст. Загрузим картинку в объект Image
и запишем в переменную.
Для тестирования напишем функцию, которая будет в цикле запускать рендер по пять раз на итерацию и записывать среднее для каждой итерации время в массив:
function test (renderFunction, from, to, samplesCount) {
const result = [];
for (let digit = from; digit <= to; digit++) {
const count = 2 ** digit;
const itterationResults = [];
for (let sample = 1; sample <= samplesCount; sample++) {
context.reset()
const startTime = performance.now();
renderFunction(count);
const endTime = performance.now();
const runTime = endTime - startTime;
itterationResults.push(runTime);
}
const sumTime = itterationResults.reduce((acc, value) => {
return acc + value;
}, 0);
result.push({
count: count,
time: sumTime / samplesCount
});
}
return result;
}
Способ 1: drawImage
Самый простой способ отрисовать исходное изображение - использовать функцию context.drawImage()
, в которую мы передадим наше изображение в виде объекта Image
, расположение на канвасе, и размеры изображения, до которых хотим его масштабировать.
Исходные размеры картинки у меня 450х450, а отрисовывать её будем размером 50x50. Для выбора координат отрисовки будем использовать рандом. Для этого напишем простую функцию на основе Math.random()
:
function random (value) {
return Math.random() * value;
}
Теперь пора написать саму функцию рендера:
function render1 (count) {
for (let i=0; i < count; i++) {
context.drawImage(sprite, random(800), random(450), 50, 50);
}
}
Запускаем тест и получаем данные. Для удобства собрал результаты в график
Тут стоит упомянуть, что функция получения рандомного числа может давать дополнительную нагрузку. Можно заранее создать массив случайных чисел и при отрисовке использовать уже готовые значения координат.
Вот график без нагрузки, вносимой "рандомизацией в реальном времени"
Хоть изменения и незначительны, в дальнейшем сравнении способов отрисовки, будем использовать значения времени без учета рандомизации (значения красного графика).
Способ 2: оптимизированный drawImage
В текущей реализации рендера, мы каждый раз просим отрисовать большую картинку с уменьшением размера до 50x50 пикселей. Вместо того, чтобы заставлять CPU тысячи раз уменьшать одно и то же изображение до одинакового размера, мы можем сами изменить этот размер, и отрисовывать спрайт уже без изменений.
Создадим новый Image
, в который поместим нашу картинку, но уменьшенную до нужных размеров:
let imaginaryCanvas = document.createElement("canvas");
imaginaryCanvas.width = 50;
imaginaryCanvas.height = 50;
let imaginaryContext = imaginaryCanvas.getContext("2d");
imaginaryContext.drawImage(sprite, 0, 0, 50, 50);
let resizedSprite = await new Promise(resolve => {
const img = document.createElement("img");
img.onload = () => {
resolve(img);
};
img.src = imaginaryCanvas.toDataURL();
});
В итоге функция рендера будет иметь такой вид:
function render (count) {
for (let i=0; i < count; i++) {
context.drawImage(resizedSprite, rands[i][0], rands[i][1]);
}
}
Назовем способ " drawImage* " и поместим на график для сравнения
Несмотря на проведенные манипуляции, нам удалось сократить время рендера миллиона спрайтов всего на 400ms (~7%). Это примерно по 0.0004ms на спрайт. Улучшения не назвать впечатляющими, поэтому необходимо найти другие решения.
Способ 3: putImageData
При использовании drawImage
, мы заставляем canvas читать данные из объекта Image
. Что, если данные о пикселях передавать в виде RGBA массивом чисел? У контекста есть метод, позволяющий отрисовывать области попиксельно, используя объект ImageData
. Давайте создадим этот объект с данными нашего спрайта:
let imaginaryCanvas = document.createElement("canvas");
imaginaryCanvas.width = 50;
imaginaryCanvas.height = 50;
let imaginaryContext = imaginaryCanvas.getContext("2d");
imaginaryContext.drawImage(sprite, 0, 0, 50, 50);
let spriteData = imaginaryContext.getImageData(0, 0, 50, 50);
Внесем правки в функцию рендера:
function render (count) {
for (let i=0; i < count; i++) {
context.putImageData(spriteData, rands[i][0], rands[i][1]);
}
}
Запустим тест:
Говоря прямо, результаты катастрофически ужасные. Если честно, я ждал расчет теста больше 20 минут. Судя по всему, из-за того, что мы "скармливаем" GPU "сырые" пиксели, без метаданных, видеокарта эти данные не может кешировать, поэтому для отрисовки каждого спрайта приходится каждый раз заново запрашивать один и тот же массив пикселей из оперативной памяти.
Предлагаю отбросить настолько неподходящий способ и попробовать по-другому. Этот способ даже не будем учитывать в сравнении на графике - для сохранения наглядности.
Способ 4: drawImage с OffScreenCanvas
Если необходимость использовать кеш графического ускорителя настолько влияет на производительность, то можно пойти ещё дальше. Насколько мне известно, современные браузеры стараются хранить состояние canvas
не в RAM а в видеопамяти видеокарты. Метод отрисовки спрайта context.drawImage
может получать на вход не только объект Image
, но и некоторые другие виды представления изображений в браузере. Нас интересует способность drawImage
принимать в качестве входных данных другие канвасы. Таким образом мы можем создать дополнительный канвас, отрисовать на нем наш спрайт, а данные этой отрисовки будут лежать не в оперативной памяти, а в собственной памяти видеоускорителя.
Создадим OffscreenCanvas
. Он обладает всеми свойствами обычного, но не имеет представления в DOM
, поэтому так будет правильней. Загрузим в него наше изображение.
let offscreen = new OffscreenCanvas(50, 50);
let offscreenContext = offscreen.getContext("2d");
offscreenContext.drawImage(resizedSprite, 0, 0);
В очередной раз перепишем функцию рендера:
function render (count) {
for (let i=0; i < count; i++) {
context.drawImage(offscreen, rands[i][0], rands[i][1]);
}
}
Запустим тест. Отразим результаты на графике сравнения:
О, чудо! Видим, что сокращение обращений к RAM для отрисовки даёт нам огромный прирост скорости рендеринга. DrawImage
из объекта Canvas
тратит на 2000ms меньше, чем drawImage
из объекта Image
(~38%).
Вывод
context.drawImage
из Image
Плюсы: Самый простой способ "из коробки". Поддерживает context.tranform
.
Минусы: Далеко не самый быстрый. Невозможны неаффинные преобразования.
context.drawImage
из Image
с предварительной подготовкой спрайтов
Плюсы: Слегка (примерно на 7%) быстрее простого drawImage
из Image
. Поддерживает context.tranform
.
Минусы: Требует некоторого количества предвычислений и обработки спрайтов. Невозможны неаффинные преобразования.
context.putImageData
Плюсы: Тут сложно сказать. Имеет смысл только для глубокой попиксельной отрисовки. Удобен для редактирования частей уже отрисованного canvas
. Возможны аффинные преобразования.
Минусы: Требует предварительной подготовки. Не поддерживает context.transform
. Ужасно медленно работает (условно, 200 отрисовок занимает почти 15ms, что критично для поддержания стабильных 60 fps).
context.drawImage
из Canvas
(OffscreenCanvas
)
Плюсы: Вероятно, самый быстрый способ работы с 2D графикой в canvas
. Поддерживает context.tranform
.
Минусы: Требует предварительной подготовки. Базово невозможны неаффинные преобразования.
Получается, что наиболее практично использовать drawImage
с Image
при небольших отрисовках, так как это не требует значительных усилий от программиста, а при необходимости оптимизации скорости и ресурсоемкости отрисовки (к примеру для игр) использовать drawImage
с OffscreenCanvas
.