Введение

Не так давно помогал брату сделать проект для курсовой. Необходимо было создать клиент - серверное приложение, и решено было создать небольшую браузерную игру с мультиплеером. Курсовая была сдана успешно, а у меня появилось желание сравнить различные возможные методы отрисовки изображений 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);
    }
}

Запускаем тест и получаем данные. Для удобства собрал результаты в график

Зависимость затрачиваемого времени от количество отрисовываемых спрайтов функцией context.drawImage
Зависимость затрачиваемого времени от количество отрисовываемых спрайтов функцией context.drawImage

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

Вот график без нагрузки, вносимой "рандомизацией в реальном времени"

Зависимость затрачиваемого времени от количество отрисовываемых спрайтов функцией context.drawImage с заданными координатами
Зависимость затрачиваемого времени от количество отрисовываемых спрайтов функцией context.drawImage с заданными координатами

Хоть изменения и незначительны, в дальнейшем сравнении способов отрисовки, будем использовать значения времени без учета рандомизации (значения красного графика).

Способ 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* " и поместим на график для сравнения

Сравнение метода drawImage с оптимизацией масштабирования и без
Сравнение метода 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]);
    }
}

Запустим тест:

График с методом putImageData
График с методом putImageData

Говоря прямо, результаты катастрофически ужасные. Если честно, я ждал расчет теста больше 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]);
    }
}

Запустим тест. Отразим результаты на графике сравнения:

График с использованием OffscreenCanvas
График с использованием OffscreenCanvas

О, чудо! Видим, что сокращение обращений к 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.

Комментарии (5)


  1. Sasa111222333
    03.11.2024 12:19

    хороший материал, спасибо


  1. ALapinskas
    03.11.2024 12:19

    Тоже пробовал писать игры в canvas и в итоге перешел на webgl. В canvas можно рисовать элементарные вещи - картинку, спрайт, линию, прямоугольник, круг, можно написать крестики-нолики, шахматы итп. Но если нужно, что-то посложнее - тайловые карты, маски, свет, тени - это уже стандартными методами сделать не выйдет.


    1. limonikal Автор
      03.11.2024 12:19

      Тут не могу не согласиться. Да, 2d контекст может достаточно быстро отрисовывать спрайты, но для геймдева его возможности сильно ограничены.

      Однако, для создания динамичных двумерных интерфейсов, как это, например, делают в Google Sheets, 2d контекст подходит отлично.


  1. Ekseft
    03.11.2024 12:19

    context.drawImage(sprite, random(800), random(450), 50, 50);

    Стало интересно, а если мы рисуем спрайт на координатах (800, 450), с размерами 50х50, получается, что всё изображения будет невидимо (кроме одного пикселя?), в таком случается отрисовка всё равно происходит полностью? Или в canvas'е есть какие-то оптимизации на этот счёт? Поменяются ли в таком случае результаты замеров?


    1. limonikal Автор
      03.11.2024 12:19

      Интересный вопрос!)

      Если передать в drawImage координаты [800 : 450], то мы не увидим даже одного пикселя. По сути, мы передаем в фукцию индексы строки и столбца, на пересечении которых находится первый пиксель нашего изображения. Так как эти индексы (считай координаты) начинают нумероваться с 0, изображение с таким адресом будет полностью отрисовано за пределами видимой области канваса.

      Интересно будет провести подобный эксперимент. По опыту скажу, что при отрисовке за пределами канваса, рендер занимает на порядки меньше времени, поэтому - да, в канвасе явно есть оптимизация на такой случай.