Как раскрыть мощь HTML5 Canvas для игр

в 12:30, , рубрики: canvas, html5, optimization, performance, Блог компании Microsoft, Веб-разработка

Как раскрыть мощь HTML5 Canvas для игр

Браузеры, поддерживающие HTML5, и платформа HTML5 для Windows 8 Metro сегодня становятся серьезными кандидатами для разработки современных игр.

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

Цель данной статьи — дать вам несколько подсказок, как выжать максимум мощности из HTML5 Canvas. Статья состоит из двух основных частей [вы читаете первую]. David Rousset скоро опубликует вторую часть.

В статье я буду показывать ключевые идеи на одном и том же примере — это эффект 2D-туннеля, который я написал для Coding4Fun-сессии на TechDays 2012 во Франции.

На написание данного эффекта меня вдохновил мой код для Commodore AMIGA, который я написал, когда был молодым автором демо-сцен в далеких 80х :). Сегодня он использует только canvas и Javascript, хотя изначальный код базировался только на 68000 ассемблере:

Как раскрыть мощь HTML5 Canvas для игр
Пример на JSFiddle (учтите, что текстура зашита в код).

Полный код доступен для скачивания тут: http://www.catuhe.com/msdn/canvas/tunnel.zip (копия на Я.Народ).

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

Использование скрытого canvas для чтения данных изображения

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

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

В таких случаях вам необходим доступ к внутренним данным изображения. Но image-тег не предоставляет способа для считывания содержимого. Вот тут-то на помощь и приходит canvas!

В сущности, каждый раз, когда вам нужно считать содержимое изображения, вы можете использовать невидимый (не выводимый на экран) canvas. Ключевая идея заключается в том, чтобы загрузить изображение, и, когда оно загружено, вам остается только отобразить его на canvas-элемент, не вставленный в DOM. Теперь у вас есть доступ к [скопированным] пикселям исходного изображения через соответствующие пиксели canvas (что очень просто).

Код для этой техники выглядит следующим образом (используется в эффекте 2D-туннеля для чтения текстуры туннеля):

var loadTexture = function (name, then) {
    var texture = new Image();
    var textureData;
    var textureWidth;
    var textureHeight;
    var result = {};

    // on load
    texture.addEventListener('load', function () {
        var textureCanvas = document.createElement('canvas'); // off-screen canvas

        // Setting the canvas to right size
        textureCanvas.width = this.width; // <-- "this" is the image
        textureCanvas.height = this.height;

        result.width = this.width;
        result.height = this.height;

        var textureContext = textureCanvas.getContext('2d');
        textureContext.drawImage(this, 0, 0);

        result.data = textureContext.getImageData(0, 0, this.width, this.height).data;

        then();
    }, false);

    // Loading
    texture.src = name;

    return result;
};

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

// Texture
var texture = loadTexture("soft.png", function () {
    // Launching the render
    QueueNewFrame();
});

Использование возможности аппаратного масштабирования

Современные браузеры и Windows 8 поддерживают аппаратное ускорение canvas. Это, в частности, означает, что вы можете использовать GPU для масштабирования контента в canvas.

В случае с эффектом 2D-туннеля алгоритм требует обработки каждого пикселя canvas. К примеру, для canvas размером 1024x768 необходимо обработать 786432 пикселей. Чтобы обеспечить непрерывность отображения, это нужно делать 60 раз в секунду, что соответствует обработке 47185920 пикселей в секунду!

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

Повторю, canvas предоставляет такое средство! Следующий код показывает, как использовать аппаратное ускорение для масштабирования внутреннего буффера canvas до внешнего размера DOM-объекта::

// Setting hardware scaling
canvas.width = 300;
canvas.style.width = window.innerWidth + 'px';
canvas.height = 200;
canvas.style.height = window.innerHeight + 'px';

Обратите внимание на разницу между размером DOM-объекта (canvas.style.width и canvas.style.height) и размером рабочего буффера canvas (canvas.width и canvas.height).

При наличии разницы между этими двумя размерами используются возможности железа для масштабирования рабочего буффера — в нашем случае это просто великолепная функция: мы можем работать в меньшем разрешении и позволить GPU смасштабировать результат, чтобы заполнить DOM-объект (с использованием прекрасного бесплатного фильтра размытия для сглаживания результата).

В данном примере рендеринг производится в области 300x200, а GPU масштабирует до размеров вашего окна.

Эта возможность широко поддерживается всеми современными браузерами, поэтому вы можете рассчитывать на нее.

Оптимизация цикла отрисовки

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

RequestAnimationFrame

Одна из интересных возможностей, пришедших с HTML5, это функция window.requestAnimationFrame. Вместо использования window.setInterval для создания таймера, вызывающего ваш цикл отрисовки каждые (1000/16) миллисекунд (чтобы достичь заветных 60fps), вы можете делегировать эту ответственность на браузер с помощью requestAnimationFrame. Вызов данного метода говорит, что вы хотите, чтобы браузер вызвал ваш код сразу же, как только будет возможно обновить графическое представление.

Браузер включит ваш запрос внутрь своего расписания отрисовки и синхронизирует вас со своим кодом отрисовки и анимации (CSS, переходы, и т.д.). Это решение также интересно в связи с тем, что ваш код не будет вызываться, когда окно не видно (свернуто, полностью перекрыто и т.д.).

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

Код довольно очевиден (обратите внимание на использование браузерных префиксов):

var intervalID = -1;
var QueueNewFrame = function () {
    if (window.requestAnimationFrame)
        window.requestAnimationFrame(renderingLoop);
    else if (window.msRequestAnimationFrame)
        window.msRequestAnimationFrame(renderingLoop);
    else if (window.webkitRequestAnimationFrame)
        window.webkitRequestAnimationFrame(renderingLoop);
    else if (window.mozRequestAnimationFrame)
        window.mozRequestAnimationFrame(renderingLoop);
    else if (window.oRequestAnimationFrame)
        window.oRequestAnimationFrame(renderingLoop);
    else {
        QueueNewFrame = function () {
        };
        intervalID = window.setInterval(renderingLoop, 16.7);
    }
};

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

var renderingLoop = function () {
    ...

    QueueNewFrame();
};

Доступ к DOM (Document Object Model)

Чтобы оптимизировать свой цикл отрисовки, вам следует использовать как минимум одно золотое правило: НЕ ОБРАЩАЙТЕСЬ К DOM. Хотя современные браузеры специально оптимизированы в этом месте, чтение свойств DOM-объектов по-прежнему слишком медленное для быстрого цикла отрисовки.

Например, в моем коде я использовал профилировщик Internet Explorer 10 (доступный в инструментах разработчика по F12) и результаты очевидны:

performance DOM

Как вы можете видеть, доступ к ширине и высоте canvas занимает очень много времени в цикле отрисовки!

Изначальный код выглядел так:

var renderingLoop = function () {


    for (var y = -canvas.height / 2; y < canvas.height / 2; y++) {
        for (var x = -canvas.width / 2; x < canvas.width / 2; x++) {

            ...

        }
    }
};

Вы можете заменить свойства canvas.width и canvas.height двумя переменными с заранее заведенными правильными значениями:

var renderingLoop = function () {

    var index = 0;
    for (var y = -<b>canvasHeight</b> / 2; y < <b>canvasHeight</b> / 2; y++) {
        for (var x = -<b>canvasWidth</b> / 2; x < <b>canvasWidth</b> / 2; x++) {
            ...
        }
    }
};

Просто, не правда ли? Возможно, что не очень просто понять, но, поверьте мне, это стоит попробовать!

Предварительные вычисления

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

[пер.: Хотя, с технической точки, зрения можно предположить, что конкретная реализация JS-runtime может опираться на аппаратную инструкцию fpatan, этого априорно никто не гарантирует. Спецификация ECMAScript5 относительно математических функций говорит о том, что они (очевидно) являются аппроксимациями и рекомендует использовать алгоритмы математической библиотеки Sun Microsystems (http://www.netlib.org/fdlibm). В любом случае, какой бы ни была реализация функции atan2, это не делает ее элементарной и быстрой.]

performance atan2

В целом, если вы можете организовать предварительный расчет для некоторого долго работающего кода, это всегда хорошая идея. Здесь прежде, чем запустить мой цикл отрисовки, я рассчитываю значения для Math.atan2:

// precompute arctangent
var atans = [];

var index = 0;
for (var y = -canvasHeight / 2; y < canvasHeight / 2; y++) {
    for (var x = -canvasWidth / 2; x < canvasWidth / 2; x++) {
        atans[index++] = Math.atan2(y, x) / Math.PI;
    }
}

Массив atans далее может использовать внутри цикла отрисовки для явного повышения производительности.

Избегайте использования Math.round, Math.floor и parseInt

Пункт на счет использования parseInt имеет смысл в нашем случае:
performance parseInt

Когда вы работаете с canvas, для указания на пиксели необходимо использовать целочисленные координаты (x и y). Однако все ваши вычисления производятся с использованием чисел с плавающей точкой и рано или поздно вам нужно будет конвертировать их в целые числа.

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

Вспоминая мой старый код на ассемблере, я решил применить небольшой трюк: вместо использования parseInt достаточно просто сдвинуть число вправо на 0. Среда выполнения переместит число с плавающей точкой из соответствующего регистра в целочисленный и применить аппаратное преобразование. Сдвиг числа вправо на 0 оставит число без изменений и вернет вам назад целочисленное значение.

Исходный код был таким:

u = parseInt((u < 0) ? texture.width + (u % texture.width) : (u >= texture.width) ? u % texture.width : u);

Новый код выглядит так:

u = ((u < 0) ? texture.width + (u % texture.width) : (u >= texture.width) ? u % texture.width : u) >> 0;

Конечно, это решение требует, чтобы вы были наверняка уверены в корректности передаваемого числа :)

Финальный результат

Применение всех описанных оптимизаций приводит к следующему отчету:
final performance

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

Мы начали с такого оригинального неоптимизированного туннеля:

Как раскрыть мощь HTML5 Canvas для игр
Пример на JSFiddle

И пришли к такому результату после оптимизации:

Как раскрыть мощь HTML5 Canvas для игр
Пример на JSFiddle

[пер.: данные скриншоты сделаны на комьютере переводчика, на котором fps не достигает заветных 60fps, но крутится довольно близко к этому — около 50fps в IE10.]

Мы можем оценить вклад каждой оптимизации следущей диаграммой, показывающей частоту кадров на моем компьютере:

comparing performance

Двигаясь дальше

Помня об этих ключевых моментах, теперь вы готовы к разработке быстрых и плавных игр для современных браузеров и Windows 8!

Автор: kichik


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js