28 November 2014

Разработка игры в 115 кб — хаки, баги и досада

DelphiGame development
Sandbox

В начале ноября я участвовал в 115-ом по счете конкурсе сообщества Independent Games Developers Contests (IGDC), темой которого была разработка аркадного шутера с лимитом в 115 килобайт за неделю. Под катом история разработки игры на OpenGL + Free Pascal, эксперименты с LZO, обход багов компилятора FPC для uFMOD, простейшая генерация текстур и досадный баг на видеокартах NVidia, который все испортил.

Видео, бинарник для Windows и исходный код также прилагаются — ищите в конце статьи.

Лирическое вступление


Разработка игр — мое основное, любимое и очень давнее хобби. Я кручусь в любительском gamedev без особых успехов, громких релизов и титанических долгостроев около 10 лет. Замороженных проектов много, доведенных до ума — единицы. В какой-то момент я отчаялся, что у меня ничего не выходит. А потом осознал, что мне нравится не только конечный результат, но и сам процесс разработки игр. С этого момента жить стало спокойнее, но я все еще не отпускаю мысль, что когда-нибудь пересилю себя и выведу какой-нибудь проект на коммерческий уровень.

В какой-то момент я набрел на сообщество IGDC, на котором проводятся короткие (от пары дней до 3 недель) конкурсы по разработке игр на заданную тему. Очень, знаете ли, теплые и ламповые конкурсы, в которых главное — участие, а не приз. Полученный опыт и удовольствие от проделанной работы, а не маркетинг и монетизация.

В наше время, когда порог вхождения в gamedev снизился в разы, и поток откровенно неудачных игр заполоняет мобильные платформы, авторы которых мечтают заработать на безбедную жизнь очередным Flappy Bird… В такое время трудно найти людей, занимающихся разработкой игр ради удовольствия, а не выгоды.

Конечно, есть могучий Ludum Dare, но его хардкорные сроки пока что идут вразрез с моей семейной жизнью.

Начало


Итак, 7 ноября 2014 г. на сайте сообщества объявляется очередной конкурс. Условия:

  • «Пыщ-пыщ» и враги — именно такими словами Ведущий конкурса охарактеризовал аркадный шутер
  • Размер — строго до 115 килобайт, ведь конкурс — 115-й по счету
  • Срок — неделя

К этим условиям добавляются постоянные обязательные условия: должно работать offline и без установки сторонних пакетов и redistributable. Все, что требуется для запуска игры и не идет в комплекте с системой, должно поставляться вместе с релизом и укладываться в те самые 115 килобайт.

Ограничение неприлично большое для true demoscene (4к, 32к...), но достаточное, чтобы как следует «извратиться». Беглый анализ средств разработки дает довольно внушительный список того, что укладывается в эти требования:

  • Flash
  • html5 + js
  • С, C++, C#
  • Delphi, FreePascal
  • ...

За бортом остаются Unity, Cry Engine, Unreal Engine, JVM-based языки (требуется предустановленный jre), а также большая часть игровых конструкторов.

О Flash
Несмотря на то, что для Flash необходим установленный Adobe Flash Player, он все же разрешен для использования, в качестве исключения (считается, что Adobe Flash Player все же стоит у большинства). Так сложилось исторически.

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

Во-первых, в 2014 году Free Pascal (как и Delphi) считается немодным — как следствие, компилятор FPC имеет мало пользователей и немало багов, несмотря на Open Source и кроссплатформенность. Во-вторых, размер скомпилированного ехе у связки Lazarus IDE + FPC — повод для отдельной страницы в wiki. В-третьих, очень мало синтаксического сахара, что остро ощущается, когда постоянно используешь много других языков и технологий.

Конечно же, есть и плюсы:

  • Правильно приготовленный ехе самодостаточен, при этом сравним с ехе от С/С++ со статической линковкой CRT
  • С настройками по умолчанию, не позволяет выстрелить себе в ногу, как в С/С++
  • С нужными настройками отстреливает напрочь обе ноги (что иногда хочется)
  • Совершенно случайно, у меня уже есть мини-фреймворк на Free Pascal + OpenGL

И я уже делал на нем вольный ремейк Lunar Lander


Поехали!



Первым делом я определился с концептом будущей игры — аркадный 2D-шутер с видом сверху, в котором игрок управляет «танком» и всячески отстреливает толпы врагов, с которых валятся бонусы для еще более веселого отстрела врагов. Вакхналия продолжается, пока смерть не разлучит вас с вашим альтер-эго. Ближайший аналог — Crimsonland.

Привычным движением откомпилировав шаблон, я создал себе первую проблему — скомпилированный ехе с моим фреймворком с учетом всех хитрых опций компилятора занимал 120 килобайт. Конечно, с учетом того, что фреймворк умеет (и держа в уме что это все-таки FPC) — это даже достижение. Но нас это абсолютно не устраивает, поэтому безжалостно режем ехе с помощью UPX — 48 килобайт. С этим уже вполне можно работать.

Конечно, тут еще можно было выиграть несколько килобайт, если урезать функционал фреймворка до самого необходимого. Я от этого отказался по причине нехватки времени, как оказалось впоследствии — не зря. В итоге лимита в 115 килобайт мне хватило с избытком.

Внедряем LZO


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

Поэтому задача — выводить текст на экран силами OpenGL. Не прибегая к архаичному способу вывода векторного текста, следует использовать растровые шрифты (Bitmap Fonts). Мой фреймворк уже имел поддержку вывода текста с использованием заранее сгенерированных и заботливо «запеченых» растровых шрифтов. Задача решена?

Коротко о реализации
Есть собственная велосипедная утилита, которая пакует нужные символы относительно компактно, а затем «запекает» в bmp-файл, в конец которому безжалостно дописывается служебная информация о метрике символов (координаты, размер, оригинальный размер, etc). Любой графический редактор не видит подвоха и вполне корректно открывает файл. Еще бы научить эти самые редакторы не перезаписывать весь файл целиком при сохранении, и можно было бы накладывать на такой шрифт пост-эффекты…


Нет, задача усложняется. Полученный таким образом файл с русскими и латинскими буквами (плюс спецсимволы и цифры) занимал 135 килобайт. Убираем русские символы, уменьшаем физический размер самого шрифта, изображение уменьшается вдвое по одному из измерений и, соответственно, вдвое в размере — 67 килобайт. Но это все равно никуда не годится, так как в сумме с «пустым» проектом это дает ровно 115 килобайт.

Сейчас я осознаю, что наиболее правильным и простым шагом было бы просто взять и генерировать шрифт прямо при запуске, из системного шрифта, благо «копипаст» кода несложен. Более того, в моем предыдущем фреймворке именно так шрифты и генерировались — в «рантайме» из системных шрифтов или otf/ttf файлов.

Но душа хотела романтики, а пятая точка — мучений. И я вспомнил, о том, что товарищ XProger в далеком 2010 году совершил акт насилия над библиотекой MiniLZO, дернув ее дамп и обернув его в несложные asm-инструкции. Выглядит это примерно так, в случае с извлечением:

function lzo_decompress(const CData; CSize: LongInt; var Data; var Size: LongInt): LongInt; cdecl;
asm
  DB $51
  DD $458B5653,$C558B08,$F08BD003,$33FC5589,$144D8BD2,$68A1189,$3C10558B,$331C7611,$83C88AC9
  DD $8346EFC1,$820F04F9,$1C9,$8846068A,$75494202,$3366EBF7,$460E8AC9,$F10F983,$8D83,$75C98500,$8107EB18
  DD $FFC1,$3E804600,$33F47400,$83068AC0,$C8030FC0,$83068B46,$28904C6,$4904C283,$F9832F74,$8B217204,$83028906
  DD $C68304C2,$4E98304,$7304F983,$76C985EE,$46068A14,$49420288,$9EBF775,$8846068A,$75494202,$8AC933F7
  DD $F983460E,$C12B7310,$828D02E9,$FFFFF7FF,$C933C12B,$C1460E8A,$C12B02E1,$8840088A,$88A420A,$420A8840
  DD $288008A,$113E942,$F9830000,$8B207240,$FF428DD9,$8302EBC1,$C32B07E3,$1E8ADB33,$3E3C146,$2B05E9C1
  DD $D9E949C3,$83000000,$2F7220F9,$851FE183,$EB1875C9,$FFC18107,$46000000,$74003E80,$8AC033F4,$1FC08306
  DD $F46C803,$FBC11EB7,$FF428D02,$C683C32B,$8369EB02,$457210F9,$D98BC28B,$C108E383,$C32B0BE3,$8507E183
  DD $EB1875C9,$FFC18107,$46000000,$74003E80,$8ADB33F4,$7C3831E,$F46CB03,$FBC11EB7,$83C32B02,$D03B02C6
  DD $9A840F,$2D0000,$EB000040,$2E9C11F,$2BFF428D,$8AC933C1,$E1C1460E,$8AC12B02,$A884008,$88008A42
  DD $51EB4202,$7206F983,$2BDA8B37,$4FB83D8,$188B2E7C,$8904C083,$4C2831A,$8B02E983,$831A8918,$C08304C2
  DD $4E98304,$7304F983,$76C985EE,$40188A20,$49421A88,$15EBF775,$8840188A,$188A421A,$421A8840,$8840188A
  DD $7549421A,$8AC933F7,$E183FE4E,$FC98503,$FFFE4284,$46068AFF,$49420288,$C933F775,$E9460E8A,$FFFFFECA
  DD $8B10552B,$10891445,$75FC753B,$EBC03304,$FFF8B80D,$753BFFFF,$830372FC,$5B5E04C0,$90C35D59
end;

… и подобное колдунство для собственно сжатия. Это отлично работает (хотя у меня получилось завести это не сразу), но рекомендовать такое для production-кода не стал бы. Есть легкое неудобство при отладке…

После сжатия шрифта получаем 17 килобайт вместо 67. А могли бы и 2-3 килобайта, если бы я просто внедрил генерацию на лету..


Используем uFMOD для вывода звука


Никто не хочет играть в игры без звуков или, хотя бы, музыки. До этого конкурса у меня был опыт работы с библиотекой bass, однако, ее пришлось оставить за бортом — необходимая dll съедала аж 97 килобайт. В отчетной теме конкурса упомянули про uFMOD — миниатюрную библиотеку для вывода xm-музыки, написанную на ассемблере. Забегая вперед, скажу, что ее внедрение в проект в итоге практически не повлияло на размер ехе-файла.

Но был один маленький нюанс. На более-менее современных версиях компилятора FPC (выше 2.2.х) данная библиотека не работала. И проблема кроется в неоднозначном поведении линковщика. Сомневаюсь, что смогу максимально верно описать технические аспекты данной проблемы — иными словами почему external-функции, объявленные в заголовочном файле, не видны для подключенного тут же объектного файла. Такое поведение остается на совести разработчиков компилятора. Приведу пример «обхода» данного поведения для одной из функций.

Было так:

function waveOutClose(hwo:Pointer):LongWord; stdcall; external 'winmm.dll';

А пришлось оборачивать это так:

function my_waveOutClose(hwo:Pointer):LongWord; stdcall; external 'winmm.dll' name 'waveOutClose';

function _waveOutClose(hwo:Pointer):LongWord; stdcall; public name 'waveOutClose';
begin
  Result := my_waveOutClose(hwo);
end;

И так для пары десятков необходимых библиотеке функций. Уточню, что я взял реализацию uFMOD через winmm, как самую легковесную и простую в использовании. Из минусов простоты — она позволяла проигрывать лишь один поток одновременно. Тем самым в моей игре появилась музыка, но от звуков пришлось отказаться.

Собственно, сам xm-трек взял вот тут, после чего утилитой от uFMOD превратил его в вот такой pas-файл, что сэкономило мне еще чуть-чуть места.

Генерация текстур


Перефразируя классика — какой demoscene-проект обойдется без генерации текстур? Изначально, я хотел опустить данную тему — уж больно просто и «в лоб» я сделал генерацию целой одной(!) текстуры. Но, быть может, кому-то из новичков пригодится подобный подход.

Практически для всех спрайтов в игре используется одна и та же текстура:



Просто полосатая текстура с «бордюром» вокруг для большей эстетики. С учетом color tint (то есть «домножая» нужный цвет на такую текстуру) получаем полосатые текстуры любых цветов.

Код генерации мне совершенно не нравится, его явно можно написать более оптимально. Более того, есть стойкое чувство, что бордюр рисуется неверно.

function TGame.GenerateTexture(aWidth, aHeight, aBorderSize: Integer): TglrTexture;
var
  m, m_origin: PByte;
  i, j: Integer;
  value: Byte;
begin
  m := GetMemory(aWidth * aHeight * 3);
  m_origin := m;
  for j := 0 to aHeight - 1 do
    for i := 0 to aWidth - 1 do
    begin
      if (i < aBorderSize) or (j < aBorderSize)
        or (i > aWidth - aBorderSize - 1) or (j > aHeight - aBorderSize - 1) then
        value := 196
      else
        if ((i + j) mod 16) >= 8 then
          value := 255
        else
          value := 196;
      m^ := value; m+=1;
      m^ := value; m+=1;
      m^ := value; m+=1;
    end;
  Result := TglrTexture.Create(m_origin, aWidth, aHeight, tfRGB8);
end;

Для разнообразия, оттенок красного у врагов слегка варьируется с помощью random().

Досадный баг на NVidia


После сдачи работы на конкурс, ведущий собирает архив из присланных работ и выкладывает на всеобщее обозрение. Задача участников — расставить друг другу места, за исключением себя. На оценку дается три дня, поэтому после авральной разработки можно расслабиться. Но не тут-то было!

Но один за другим конкурсанты отписываются, что моя игра работает у них некорректно — «танк» игрока не виден в принципе, не видны многие вражеские танки, иногда они мелькают. Врагов можно определить только по дыму из выхлопной трубы. Такие проблемы проявляются у всех конкурсантов на видеокартах Nvidia. Более того, один из конкурсантов имеет конфигурацию идентичную моей, но у меня баг не проявляется вовсе. Кому-то помог запуск в режиме совместимости с Windows 95(!), но таких были единицы.

Я выкладывал разные билды (что уже слегка противоречит правилам конкурса), подчищая все подозрительные места в коде, советовал разные настройки, но все было тщетно. Наконец, один из участников, самый дотошный, за что ему большое спасибо (привет, pelmenka!), обнаружил причину — если выключить в панели управления Nvidia потоковую оптимизацию (threading optimization), то игра работает корректно. Досада в том, что у меня такая настройка выключена с незапамятных времен, когда она становилась причиной BSOD-а в некоторых играх.

Благодаря этой ценной информации, я смог воспроизвести баг у себя и начать устранять его, хотя в душе уже понимал, что большая часть конкурсантов уже проголосовала, да и вообще, я — сам себе злобный буратино, что не проверил игру на стандартных настройках панели управления Nvidia.

Я не буду долго описывать процесс поиска решений проблемы. Скажу только, что в процессе поиска, я понял, что:

  • Потоковая оптимизация приносит больше проблем, чем пользы. Несложный поиск «nvidia threading optimization» дает ссылки на игровые форумы, где настоятельно советуют отключать данную настройку во избежание «моргания» игр
  • Нет никакой спецификации, которая бы объясняла, что делает данная оптимизация на уровне драйверов (или есть?)
  • Некоторое количество тем от инди-разработчиков, жаловавшихся на баги при включенной потоковой опимизации. Без ответов, кроме «просто выключи ее, приятель»

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

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

При включенной потоковой оптимизации драйвер NVidia по одному ему ведомым признакам иногда (всегда?) помещает вызовы данной функции в отдельный поток и немедленно возвращает управление, что приводит к плачевному результату. Сколько данных успеет «залиться» в буфер, прежде чем пойдет его отрисовка — неизвестно. И OpenGL-драйвер от Nvidia не видит в этом проблемы. Опережая ваш вопрос, я отвечу: нет, размер пересылаемых данных ничтожно мал, пара килобайт (особенно по сравнению с пропускной способностью шины), поэтому дело далеко не в жирных данных.

Спецификация OpenGL не говорит нам о том, что данная функция может быть запущена в отдельном потоке. Личная инициатива от ребят из Nvidia?

В случае однократного использования glBufferSubData за кадр вы ничего не заметите, потому что драйвер «закэширует» отрисовку данного буфера и вызовет ее, дождавшись окончания всех операций по этому буферу (вероятно).

А в моем случае один буфер используется несколько раз за кадр. То есть:

glBindBuffer(GL_ARRAY_BUFFER, BufferId);
glBufferSubData(GL_ARRAY_BUFFER, 0, Size, Data);
glDrawElements(...);
glBindBuffer(GL_ARRAY_BUFFER, 0);
...
glBindBuffer(GL_ARRAY_BUFFER, BufferId);
glBufferSubData(GL_ARRAY_BUFFER, 0, OtherSize, OtherData);
glDrawElements(...);
glBindBuffer(GL_ARRAY_BUFFER, 0);

И вот тут-то вы почувствуете потоковую оптимизацию во всей красе. Что в итоге будет выведено на экран, определяет слепой случай.

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

В: Почему бы не объединить данные и не отрисовать за один раз?
О: Между этими вызовами есть отрисовка других элементов, менять порядок отрисовки нельзя.

В: Почему бы не использовать разные буферы?
О: Данные в буфере обновляются каждый кадр, держать несколько буферов — трата ресурсов

Итог


В итоге я получил четвертое место на конкурсе, хотя вполне мог бы побороться за второе-третье. Досаду вызвал не тот факт, что я пролетел мимо «тройки» лидеров, а скорее осознание того, что я «запорол» годную игру и 7 дней своей работы одной маленькой неприятностью.

Короткое видео игрового процесса:



Суммарный размер релиза составил почти 80 килобайт. В них входит:

  • 62,0 кб — ехе
  • 17,3 кб — шрифт
  • 0,52 кб — шейдеры

Скачать релиз (все необходимые исходники прилагаются).
Исходники на github
Tags:gamedevelopmentdemoscenefreepascalopengl
Hubs: Delphi Game development
+58
38.9k 173
Comments 36
Popular right now
Middle/Senior Software Engineer
from 100,000 ₽dicehub GmbH - GermanyRemote job
Разработчик ПО на Delphi
from 75,000 ₽ЖивикаЕкатеринбург
Senior Game Designer (Playable Ads)
from 120,000 ₽AllcorrectRemote job