Своя игра с JavaScript и Canvas

в 17:21, , рубрики: canvas, game development, html5, javascript, метки: , , ,

imageНе так давно мне стало любопытно, насколько сносно современные браузеры поддерживают HTML5 и я не нашел лучшего
способа, чем написать простейший 2D платформер. Помимо удовольствия от разработки игрушки и улучшения навыков в использовании JavaScript, в ходе развлечения кропотливой работы был накоплен определенный опыт и эмпирическим путем были найдены основные грабли, на многие из которых мне пришлось наступить. В этой статье я попробую кратко и с примерами резюмировать то, что вынес для себя из проделанной работы. Желающих создать свое высокопроизводительное JavaScript приложение, эффективно работающее с графикой, прошу под кат.

Общие замечания

Код на JavaScript очень критичен к ресурсам платформы. Несмотря, что почти все современные движки перестали тупо интерпретировать JS код, скорость его выполнения по-прежнему очень сильно уступает скорости «родного» кода. Между тем, даже простейшая игра — это много кода, который должен успевать выполниться между отрисовками двух соседних кадров анимации. Кроме того, JS — язык весьма специфический и написание объемного кода на нем сопряжено с рядом трудностей. Все вместе может стать причиной того, что приложение на JS перестанет удовлетворять ожиданиям и быстро принесет разочарование. Попробую немного систематизировать выводы, к которым я пришел путем экспериментов.

1. Совместимость

Если мы решили использовать HTML5 и Canvas в частности, то пусть нас больше не беспокоят вопросы совместимости со старыми браузерами – под ними все равно ничего не заработает. Таким образом, можно смело использовать основные нововведения ECMAScript 5. С другой стороны, не стоит обижать презрением пользователей старого доброго ПО, наподобие IE6. Желательно их уведомить, о причинах, почему они видят фигу серый квадрат, вместо нашей замечательной анимации. Сделать это элементарно, достаточно диагностировать поддержку Canvas и используемых языковых конструкций

<canvas id="gameArea">
  <span style="color:red">Your browser doesn't supported HTML5 Canvas</span>
</canvas>

<script type="text/javascript">
(function(){
if(typeof ({}.__defineGetter__) != "function" && typeof (Object.defineProperty) != "function")
  alert("Your browser doesn't supported latest JavaScript version");})()
</script>

Все бы хорошо, да вот беда – проблемы кроссбраузерной совместимости до конца не исчерпаны. Среди современных браузеров нет единого мнения насчет названий стандартных функций. Выход – либо вообще отказаться от их использования, либо делать дорогостоящие адаптеры. Например, я не смог отказать себе в использовании описателей свойств и это дало свои негативные последствия. О том, как их использовать кроссбраузерно, хорошо описано здесь, и здесь. А вот как их заставить работать быстро — осталось загадкой.

2. Оптимизацию кода легко сломать

Не так давно на Хабре проскакивала очень полезная статья про движок V8 для Chromium. Самое главное, что я сумел почерпнуть для себя – это скрытые классы и оптимизация кода для работы с ними. Действительно, JS зачастую провоцирует менять структуру объекта после его конструирования. Не стоит этого делать, если цель – создать быстрый и легко поддерживаемый код. Как только я это осознал, работа над игрой пошла веселее, а код стал чище и быстрее.

function myObject() { };
var mo = new myObject();
mo.id = 12; //лучше так не делать
//Аккуратнее надо быть и с переменными.
var v;
v = 12; //плохо, лучше var v = 12;
v = “12”; //так не надо, для нового типа лучше использовать новую переменную
var v = 15; //я искренне верю, что так никто не поступает

Так же нужно стремиться сокращать область видимости переменной до минимума – это увеличивает вероятность оптимизации кода.

3. В JS нет классов, наследования и прочего класс-ориентированного программирования.

Не следует напрягать движок реализацией классов при помощи прототипирования – выгода сомнительна, а код замедляется в разы (Opera)! Сложное прототипное наследование и честно организованный перенос базового функционала к наследникам сбивают и без того не самую лучшую оптимизацию.

4. Платим за то, что используем

По ходу разработки игры или любого другого ресурсоемкого приложения, неизбежно приходится кэшировать «дорогие» ресурсы, например, предрасчитанные анимации или динамически загружаемые скрипты. У любого ресурса есть время жизни, после которого он оказывается не нужен. И тут важно правильно от него избавиться.

var resCache = { res1 : new getCostlyResource() }//тут может быть выделено много памяти
resCache.res1 = null; 

Скорее всего, память не будет освобождена сборщиком мусора (GC). Она будет собрана в произвольный момент времени, и он окажется самым неподходящим, потому что GC постарается удалить весь мусор, который накопится к этому моменту. Вот так уже лучше:

delete resCache.res1;
resCache.res1 = null; //полезно для отладки

На первый взгляд – ничего сложного, но в более сложных случаях появляются нюансы, и поведение delete не всегда очевидно.

5. Замыкания и свойства – враги быстродействия

Замыкания – базовая возможность функционального языка. Кажется, что именно это место должно быть максимально оптимизировано движком JS. Но, практика показывает, что это не так. Вот небольшой тест, который сравнивает быстродействие различных способов доступа к данным объекта (код теста).
Результат для разных браузеров и платформ (мс):

Windows XP (x86), Core 2 Duo, 3 GHz Opera 12 FireFox 17 Chrome 23
Нет замыканий, прямой доступ к полям объекта 9 6 17
Нет замыканий, доступ к данным через методы 16 11 28
Замыкания, доступ через методы 34 12 23
Замыкания, доступ через свойства 387 899 489
Windows 7 (x64), Core i3-2100, 3.1 GHz Opera 12 Chrome 23 IE 10
Нет замыканий, прямой доступ к полям объекта 7 5 15
Нет замыканий, доступ к данным через методы 13 11 13
Замыкания, доступ через методы 27 9 14
Замыкания, доступ через свойства 222 315 99

Как ни удивительно, Opera смотрится в тесте лучше других. К сожалению, общий вывод неутешителен, замыкания оптимизированы только в Chrome, а доступ через свойства – это большая роскошь, способная на порядок ухудшить быстродействие приложения.

Замечания о графическом движке

Программирование графики на JavaScript — отдельная большая тема, которую могу затронуть позже, если будет спрос. Здесь я постарался выделить несколько простых приемов, которые было легко использовать и они дали неплохой результат.

1. Покадровая анимация.

Есть два подхода к созданию анимации: событийный и покадровый. Первый пригоден в основном для простых задач, наподобие подсветки кнопки при наведении мышки и может выполняться в соответствующих обработчиках. Второй годится для выполнения сложных анимационных задач, которые должны «жить своей жизнью», вне зависимости от действий пользователя, например, динамические игры.
При создании игры проще всего (и дешевле в плане вычислительных ресурсов) рассчитывать игровой процесс, полагаясь на стабильность частоты кадров (frame). Можно попытаться использовать запрос кадра анимации у тех браузеров, что его поддерживают. Сделать это не так просто, потому что этот метод стал стандартом де-факто и по какой-то причине не попал в ECMAScript 5.

var raf = window.requestAnimationFrame || window.msRequestAnimationFrame ||      window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame;
//И использовать его можно примерно так:
var myRedrawFunc = function () { /*тут мог бы быть ваш код*/ raf(myRedrawFunc) }
raf(myRedrawFunc);

Выгода RequestAnimationFrame в основном состоит в том, что при ненагруженной анимации (когда сам процесс отрисовки занимает меньше половины времени кадра), он позволяет добиться большей плавности анимации и сокращает потребление ресурсов на мобильных платформах. Но на деле это может оказаться не так. К неудобствам от его использования можно отнести фиксированную частоту кадров (60 fps) и отсутствие компенсации продолжительности следующего кадра, если задержалась отрисовка предыдущего.
Однако, что делать, если raf === null? Так может произойти, если ваше приложение попало в руки Opera, которая традиционно идет своим путем. Тогда нам поможет старый добрый setTimeout. В итоге код будет выглядеть примерно так:

var fps = 60;
var frameTimeCorrection = 0;
var lastFrameTime = new Date();
var to = 1000/fps;
var myRedrawFunc = function()
{
  /* Код анимации и физического движка */
  var now_time = new Date();
  frameTimeCorrection += now_time - lastFrameTime - to;
  if(frameTimeCorrection >= to)
    frameTimeCorrection = to - 1; //ограничиваем большую коррекцию
  lastFrameTime = now_time;
 
  if(raf)
    raf(redrawFunc);
  else
    setTimeout(myRedrawFunc, to - frameTimeCorrection);
};
myRedrawFunc ();

Недостаток у такого подхода очевиден – если расчёт кадров будет подтормаживать больше, чем можно скорректировать – игровой процесс перестанет рассчитываться в реальном времени. Но можно пойти на хитрость. Как правило, причина тормозов — отрисовка элементов очередного кадра, т.к. для движка браузера это накладная операция. Поэтому можно написать код так, что в случае, когда коррекция времени не справляется (срабатывает условие if(frameTimeCorrection >= to)), в следующем кадре можно производить только расчет игрового мира без его перерисовки. Появится т.н. «лаг» который в игре выглядит менее раздражающе, чем slow motion.

2. Рисуем только то, что видно на холсте.

Наиболее простой и проверенный годами способ анимации – спрайты. Особенность этого способа заключается в том, что для создания иллюзии движения, спрайты перемещаются в игровом пространстве, путем изменения координат их отрисовки. Как правило, игровое пространство значительно превышает по размеру область отрисовки кадра и если игровое пространство большое, и спрайтов много их отрисовка станет занимать ощутимое время. Методы контекста канвы являются элементами DOM, а обращение к ним весьма накладно. Одна из оптимизаций заключается в том, чтобы рисовать только то, что видно в кадре. В приведенном тесте, вначале создаются и выводятся на канву 9000 «умных» спрайтов, которые при перерисовке следят за своими координатами, и не обращаются к методам канвы, если они вне кадра. Затем создаются 9000 «глупых» спрайтов, которые не следят за областью видимости (код теста).
Результаты теста (fps):

Windows XP (x86), Core 2 Duo, 3 GHz Opera 12 FireFox 17 Chrome 23
«Умные» спрайты 47 35 25
«Глупые» спрайты 15 14 12
Windows 7 (x64), Core i3-2100, 3.1 GHz Opera 12 Chrome 23 IE 10
«Умные» спрайты 56 32 61
«Глупые» спрайты 19 15 51

Разница ощутима (а Хромой снова подкачал – миф развенчан).

3. Кэшируем растеризацию

Растеризация средствами графического движка – довольно ресурсоемкое занятие. Поэтому важной оптимизацией является кэширование результатов растеризации в памяти. Самый простой способ – создать отдельную канву и в ней растеризовать векторную графику. Запомнить указатель на нее в кэше и в основной области отрисовки выводить запомненный результат. Для иллюстрации возьмем растеризацию 1000 текстовых спрайтов. В тесте производительности , попеременно с интервалом 20 сек отрисовываются 800 текстовых спрайтов. Вначале с кэшированием результата растеризации, затем без кэширования (код теста).
Результаты теста (fps):

Windows XP (x86), Core 2 Duo, 3 GHz Opera 12 FireFox 17 Chrome 23
Кэширование растеризации 23 32 60
Без кэширования 5 12 47
Windows 7 (x64), Core i3-2100, 3.1 GHz Opera 12 Chrome 23 IE 10
Кэширование растеризации 33 61 61
Без кэширования 5 56 23

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

4. Динамическая загрузка ресурсов

Если анимация строится на спрайтах из битовых карт (bitmap), то прежде, чем их можно будет рисовать на холсте, следует загрузить эти самые карты в кэш изображений браузера. Для этого достаточно создать элемент Image и в качестве источника передать url ресурса картинки. Сложность состоит в том, чтобы дождаться момента, когда браузер загрузит картинку в свой кэш. Для этого можно использовать событие onload, в котором, увеличивать счетчик уже загруженных картинок. Как только значение это счетчика совпадет с числом картинок, добавленных к загрузке, ресурс станет персистентным, и мы можем выполнять основной игровой код.

function Cache()
{
  var _imgs = {};
  var _addedImageCount = 0;
  var _loadedImgsCount = 0;
  this.addSpriteSource = function(src)
  {
    var img = new Image();
    img.onload = function()
    {
      _loadedImgsCount++;
    };
    img.src = src;
    _imgs[src] = img;
    _addedImageCount++;
  }
  this.getLoadedImagePc()
  {
    return _loadedImgsCount * 100 / _addedImageCount;
  }
  this.getImage = function(src)
  {
    return _imgs[src];
  }
}
//добавляем картинки
Cache.addSpriteSource("img1.jpg");
Cache.addSpriteSource("img2.jpg");
//ждем, пока они загрузятся
function waitImagesLoading()
{
  var pc = Cache. getLoadedImagePc();
  if(pc < 100)
    setTimeout(waitImagesLoading, 200);
  /* при необходимости тут можно анимировать процент загрузки картинок*/
}
waitImagesLoading();

В своей игрушке я решил каждый уровень описывать отдельным скриптовым файлом. Понятно, что статическая загрузка таких скриптов вредна, т.к. в каждый момент времени нужен только один из них. Решению задачи помог тот же подход, что и в случае загрузки изображений.
Есть только один нюанс — у объекта Script нет событий, но это и не беда, т.к. в код динамически загружаемых скриптов можно вставить глобальную функцию регистрации скрипта в кэше. Дальше поступаем аналогично загрузке картинок — асинхронно ждем, пока скрипт зарегистрирует описанные в нем типы, а затем создаем нужные нам экземпляры зарегистрированных типов.
Для того чтобы пользователь не скучал — можно показать процент загрузки всех необходимых ресурсов.

5. Дробные координаты

Рисуя на канве растровые или векторные примитивы, можно указывать дробные координаты и размеры. В результате графический движок браузера вынужден сглаживать выводимое на экран изображение. Если упрощать, то это происходит, потому что виртуальный пиксель растеризованного изображения не будет совпадать с пикселем на экране. Как результат — включатся алгоритмы сглаживания изображения (smoothing), что может заметно сказаться на производительности.
В тесте производительности , попеременно с интервалом 20 сек спрайты с целыми и с дробными координатами (код теста).
Результаты теста (fps):

Windows XP (x86), Core 2 Duo, 3 GHz Opera 12 FireFox 17 Chrome 23
Целые координаты и размеры 57 60 60
Дробные координаты и размеры 50 52 60
Windows 7 (x64), Core i3-2100, 3.1 GHz Opera 12 Chrome 23 IE 10
Целые координаты и размеры 60 61 61
Дробные координаты и размеры 60 61 61

Тут следует пояснить, что в случае 64-битной платформы положение спасает более хороший графический адаптер, который очевидно берет на себя задачу сглаживания и антиалиасинга. В случае относительно быстро перемещаемых спрайтов (десятки пикселов за секунду) можно обходиться целыми координатами и размерами. Однако сами координаты и размеры нужно считать в дробных величинах, чтобы не терять точность при плавном изменении параметров. Вполне оправдывает себя такой подход, когда все значения координат и размеров рассчитываются и хранятся без округлений, а перед непосредственным выводом на канву, они округляются с помощью Math.floor.

Вместо заключения.

Современное развитие JavaScript, HTML5 и поддержка этих возможностей различными браузерами уже вполне позволяют писать производительные интерактивные графические приложения, которые в большинстве задач дадут фору традиционному flash программированию.

Автор: gasizdat

Источник

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


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