high_impact


tl;dr: high_impact — это маленький игровой движок для 2D-игр жанра «экшн». Он написан на C, компилируется для Windows, Mac и Linux, а также для WASM в вебе. Он был написан «по мотивам» моего игрового движка Impact на JavaScript, разработанный в 2010 году. Название high_impact — отсылка к тем временам, когда C считался языком высокого уровня.

Движок имеет лицензию MIT, исходники выложены на Github: github.com/phoboslab/high_impact

Видео из моего твита за 5 июля, демонстрирующее геймплей Biolab Disaster

Древняя история


В апреле 2010 года Стив Джобс опубликовал открытое письмо «Thoughts on Flash», в котором поделился решением не поддерживать Flash в iOS.

Flash — это браузерный плагин, который был настолько важен для веба (до того момента), что поставлялся в комплекте с браузерами и с обновлениями Windows. Веб-сайты наподобие Newgrounds и Kongregate, полностью посвящённые Flash-играм и анимациям, стали эпицентром Интернет-культуры. Важность Flash невозможно переоценить: веб без Flash был скучным вебом.

Хотя Android поддерживал Flash, это было полным отстоем, и все это знали. Adobe, никогда не желавшая поступать правильно, не предприняла никаких усилий к тому, чтобы устранить недостатки технологии на мобильных. Когда Apple отказалась поддерживать в iOS эту разлагающуюся кодовую базу с закрытыми исходниками, это стало началом конца.

Без Flash не будет игр в браузере. По крайней мере, так мы считали.

В то время я искал проект для своей курсовой работы и наткнулся на технологию JavaScript Canvas2D API. Canvas2D позволяла отрисовывать изображения и фигуры в элементе <canvas> веб-сайта. Она была изобретена и реализована Apple/Safari (без процедуры стандартизации) для рендеринга десктопных виджетов: прогнозов погоды, календарей, тикеров котировок и другой не особо полезной ерунды.

Вскоре Google и Mozilla реализовали поддержку Canvas2D, а Microsoft забыла о существовании веба, но это всех устраивало, потому что никого больше не интересовал Internet Explorer. Canvas2D поддерживалась всеми серьёзными браузерами.

Итак, я решил доказать, что разработка игр для веба не требует Flash. В результате получилась игра Biolab Disaster.

Стартовый экран Biolab Disaster, с графикой, позаимствованной у невероятно талантливого Арне Никласа Янссона.

Я решил, что это успех, когда знаменитый энтузиаст Apple Джон Грубер опубликовал пост из двух предложений о моей игре, вероятно, в оправдание решения Стива Джобса.

Для создания Biolab Disaster мне нужно было написать игровой движок и редактор уровней, при этом прорываясь через бесчисленные препятствия, которые ставили ранние Canvas и Audio API. Я сделал всё, что мог, с учётом всех возможностей веба 2010 года.

Я решил подчистить свой код, написать подробную документацию, а затем выпустить Impact. Не бесплатно, а за довольно приличную сумму в $99. Моё решение вызвало много негатива, но было достаточно успешным для того, чтобы я смог уйти в свободное плавание. В итоге я продал больше трёх тысяч лицензий.

На Impact было создано множество веб-игр и он даже послужил основой для нескольких коммерческих кроссплатформенных проектов наподобие Cross Code, Eliot Quest и моей собственной игры для Nintendo Wii-U XType Plus.

В конце его жизни я сделал Impact бесплатным.

Несколько недель назад я начал снова создавать Impact с нуля, на этот раз на C вместо JavaScript.

Почему C?


C — это любопытный маленький язык. Он чрезвычайно далёк от всего того, что я пишу за деньги. Он очень прост, но крайне глубок. Можно провести параллели между ним и теми принципами, что я люблю в играх: легко научиться, сложно стать мастером.

Я постепенно начал заново влюбляться в C, сначала портировав свой MPEG1-декодер на JavaScript в единую библиотеку pl_mpeg, а затем реализовав VR в Quake for Oculus Rift; далее я создал формат изображений QOI и формат аудио QOA, а в конце переписал wipEout.

Impact был довольно простым, он несравним с Godot, Unreal или Unity. Однако он всё равно оказался прочным фундаментом для множества разных игр.

Переписывание Impact на C должно стать интересным упражнением.

Концепция


Как и в случае большинства вещей, которые я пишу для развлечения, мне хотелось сжать движок до его простейшей формы. Всё в high_impact реализовано максимально просто, в абсолютно минимальном объёме кода, который мне удалось придумать. Реализовать это на C не всегда бывает просто, но для меня это стало самой радостной частью проекта.

Основная идея high_impact осталась такой же, как и у исходного движка на JavaScript: пользователь получает инструменты для загрузки тайловых карт и создания, обновления и отрисовки игровых объектов («сущностей»). Игровой движок обрабатывает физику и распознавание коллизий между сущностями и картой коллизий. Кроме того, у high_impact имеется функциональность для работы с простыми анимациями листов спрайтов, отрисовки текста и воспроизведение звуковых эффектов и музыки.

high_impact — это не «библиотека», а скорее фреймворк. Это пустой каркас, который вы можете заполнить. Бизнес-логика пишется внутри фреймворка.

В самом фундаменте этого фреймворка находится бэкенд platform. На данный момент high_impact выполняет компиляцию с одной из двух платформ: SDL или Sokol.

Код игры находится в одной или нескольких «сценах» (например, «title_screen», «menu», «game» и так далее); сцена — это просто struct с указателями функций. Изначально мы вызываем engine_set_scene(&scene_game), и движок подготавливает новую сцену, вызывает один раз scene_game.init(), а затем scene_game.update() и scene_game.draw() в каждом кадре.

Тайловые карты и первоначальные сущности можно загружать из файла .json (при помощи моей библиотеки pl_json) или создавать на лету. Я выбрал JSON для формата уровней только для обратной совместимости с исходным движком Impact.

high_impact загружает изображения в формате QOI, а звуки/музыку — в QOA. Makefile для демо-игр настроен так, чтобы автоматически преобразовывать все ассеты в эти форматы (например, PNG в QOI, а WAV в QOA). Благодаря этому не нужно добавлять никакие другие библиотеки декодирования изображений/звука.

Возможно, в будущих версиях high_impact появится поддержка других форматов ассетов, но мне нравится, что простота этих форматов продолжает общую тему проекта.

Сущности


Все сущности (динамические объекты в игровом мире) имеют общую struct entity_t, содержащую все свойства, необходимые high_impact: позицию, скорость, размер и так далее. Каждая сущность имеет одинаковый размер в байтах, поэтому их хранение и управление ими становятся тривиальными задачами.

Для перемещения сущностей разработчик задаёт скорость или ускорение, а всем остальным занимается high_impact.

При помощи макроса high_impact позволяет расширять базовую struct сущности уникальными для каждой сущности свойствами. Как расширять эту struct, зависит от вас. В Biolab Disaster используется union с одной struct на каждый тип сущности, но есть и убедительные аргументы для использования простой «толстой struct». Как бы то ни было, в Drop не потребовалось определять никаких дополнительных свойств.

ENTITY_DEFINE(
    union {
        struct {
            float high_jump_time;
            float idle_time;
            bool flip;
            bool can_jump;
            bool is_idle;
        } player;

        struct {
            entity_list_t targets;
            float delay;
            float delay_time;
            bool can_fire;
        } trigger;

        // ...
    }
);

В игре доступ к struct в этом union можно получить так:

static void update(entity_t *self) {
    self->player.idle_time += engine.tick;
}

Кроме того, каждый из типов сущностей должен иметь entity_vtab_t, содержащую указатели функций, используемых этой сущностью:

// Вызывается в каждом кадре
static void update(entity_t *self) {
    // Здесь написанная разработчиком логика обновления
    // ...

    // Обновление физики, выполняемое high_impact
    entity_base_update(self);
}

// Вызывается, когда эта сущность пересекается с другой, где
// (self->check_against & other->group)
static void touch(entity_t *self, entity_t *other) {
    entity_damage(other, self, 10);
}

entity_vtab_t entity_vtab_blob = {
    .update = update,
    .touch = touch,
    // ...
};

Все элементы этой entity_vtab_t опциональны. Список доступных функций см. в entity.h.

Как и у большинства других частей high_impact, для всех сущностей существует хранилище фиксированного размера. По умолчанию в игре может быть 1024 активных сущности, но это можно настроить, задав ENTITIES_MAX. Движок с лёгкостью обрабатывает до 64 тысяч сущностей.

Видео с тысячей частиц из моего твита за 6 июля 2024 года

Если нужно хранить ссылку на сущность дольше одного кадра, то можно получить entity_ref_t, которая просто является struct { uint16_t id, index; }; — уникальным id этой сущности и индексом массива хранилища сущностей. Её можно резолвить снова в указатель (с очень малыми затратами) с помощью entity_by_ref(). Это гарантирует, что сущность по конкретному индексу имеет ожидаемый id, и что это не другая сущность, которая оказалась по тому же адресу хранилища после уничтожения исходной. Кроме того, именно из-за использования uint16_t для индекса установлено жёсткое ограничение в 64 тысяч активных сущностей. Если вам нужно больше, то измените исходники!

Система сущностей — один из тех моментов, которые делают работу с языком C немного неудобной. Мне бы хотелось иметь простое ООП с классами и простым наследованием, но в C для этого требуется потрудиться. Тем не менее, high_impact стремится к максимальной эргономичности.

Многие люди (не Джонатан Блоу) считают, что ООП (я использую этот термин условно) — это ошибочный подход для сущностей и что нужно вместо него применять композицию (например, реализовать полную «Entity Component System» на основе FLECS или других проектов). Однако во всех написанных мной играх оказывалось, что «наивное» ООП просто работает. Вся логика конкретного типа сущностей находится в одном месте, поэтому её очень легко анализировать.

Распознавание коллизий/реакции


Простой способ обработки коллизий игрового мира заключается в том, чтобы проверять, может ли сущность переместиться в новую позицию и если нет, останавливать её. Обычно этого вполне достаточно (это сработало для Q1k3 и Underrun), но может приводить к странному поведению быстро движущихся объектов: представьте, что в 2D-платформере игрок падает на землю: он в 16 пикселях над землёй, а на следующем шаге движения уже будет находиться внутри земли, поэтому игрок останавливается в воздухе. В следующем кадре гравитация снова применяется, и игрок движется к земле. Это выглядит как «мягкая посадка».

Вместо этого high_impact трассирует прямоугольник коллизий сущности относительно тайловой карты и вычисляет конкретную точку контакта. Это чуть сложнее, чем простая проверка «да-нет», но даёт гораздо более качественные результаты. Кроме того, high_impact может работать с наклонными тайлами, сильно компенсирующими эту трассировку. Подробности см. в trace.c.

Когда сущность сталкивается с тайлом, нам может понадобиться ещё вторая трассировка с оставшейся скоростью. Например, если мы столкнулись с землёй под углом, vel.y сущности становится равной 0, но мы не хотим останавливать её в точке контакта. Поэтому мы выполняем вторую трассировку с оставшейся vel.x, чтобы сущность соскользнула к земле.

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

Видео из моего твита за 13 июля 2024 года, демонстрирующее склоны и динамическое распознавание коллизий

Широкая фаза распознавания коллизий сортирует все сущности по их pos.x, что можно малозатратно выполнить при помощи сортировки вставками, так как сущности уже в основном отсортированы из последнего кадра. Отсортировав сущности, нам остаётся лишь пройти слева направо, проверяя каждую сущность на коллизии со всеми сущностями, находящимися в интервале от pos.x до pos.x + size.x.

Такая скользящая методика с отсечением быстра, если в близких позициях по x не слишком много сущностей. Например, наихудшим случаем для такой методики станет большая башня из стоящих друг на друге ящиков. В некоторых играх (например, в вертикальных шутерах) также может потребоваться изменение оси скольжения. Это можно сделать при помощи #define ENTITY_SWEEP_AXIS y.

Рендеринг


В настоящее время у high_impact есть два рендерера: OpenGL и программный рендерер (незавершённый). Так как весь рендеринг выполняется через очень тонкий API, а сами вызовы отрисовки используют одну функцию, реализовать разные бэкенды можно достаточно легко. Дополнительный бэкенд рендеринга должен поддерживать следующие функции:

void render_backend_init(void);
void render_backend_cleanup(void);

void render_set_screen(vec2i_t size);

void render_frame_prepare(void);
void render_frame_end(void);

void render_draw_quad(quadverts_t *quad, texture_t texture_handle);

Плюс ещё три для обработки текстур:

texture_mark_t textures_mark(void);
void textures_reset(texture_mark_t mark);

texture_t texture_create(vec2i_t size, rgba_t *pixels);

Разумеется, всё это очень упрощено: можно отрисовывать только четырёхугольники и нельзя использовать никакие шейдерные эффекты, но для задач игрового движка этого вполне достаточно.

Программный рендерер состоит всего из 140 строк кода (см. render_software.c), хотя я немного сжульничал — в нём поддерживаются только выровненные по осям четырёхугольники.

Рендерер OpenGL (см. render_gl.c) чуть сложнее, потому что он пытается уместить рендеринг всего кадра в один вызов отрисовки OpenGL. Это реализуется двумя способами:

  1. все отрисовываемые четырёхугольники собираются в большой буфер и передаются за раз OpenGL при помощи glDrawElements(),
  2. все текстуры объединяются в один текстурный атлас. Нам никогда не приходится заново привязывать текстуры.

Текстурные атласы довольно олдскульны и имеют собственные недостатки. Строго говоря, необходимость в них отсутствует благодаря bindless-текстурам, но они поддерживаются не везде.

Хотя high_impact поддерживает только один текстурный атлас, его размер можно настроить при помощи #define. Мобильные GPU обычно поддерживают текстуры 8k×8k, а современные десктопные GPU — максимум 32k×32k. Для нашего движка вполне достаточно. В Biolab Disaster и Drop используется атлас 512×512.

Звук


Выводом звука занимается SDL2 или Sokol, так что нам нужно обрабатывать только загрузку, декодирование и микширование звуков. Звуковая система разбита на sound_source_t, содержащую сэмплы, и sound_t, описывающую звук, в текущий момент воспроизводимый одним из источников.

Эта система стала адаптацией системы, написанной мной для wipEout, она может распаковывать QOA по требованию.

Память под всё выделяется статически. Можно загружать фиксированное количество источников и одновременно воспроизводить фиксированное количество звуков. Звуки после завершения воспроизведения автоматически удаляются, чтобы их можно было использовать повторно. Вся система спроектирована так, чтобы об этом не нужно было особо задумываться.

Например, вот как загружается/воспроизводится звук отскакивания сущности игрока в Drop:

static sound_source_t *sound_bounce;

static void load(void) {
    sound_bounce = sound_source("assets/bounce.qoa");
}

static void collide(entity_t *self, vec2_t normal, trace_t *trace) {
    if (normal.y == -1 && self->vel.y > 32) {
        sound_play(sound_bounce);
    }
}

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

Документацию см. в sound.h, а подробности реализации — в sound.c.

Управление памятью


Это интересная часть проекта. Управление памятью в C часто считают какой-то чёрной магией, а во многих туториалах и библиотеках сильно всё усложняют. В играх всё можно сделать гораздо проще. На самом деле, в high_impact практически не нужно думать о памяти.

В случае игр, не имеющих создаваемых пользователем ассетов, мы точно знаем необходимый объём памяти. Или самая крупная сцена помещается в него, или нет. Поэтому high_impact статически выделяет единый массив байтов, названный «hunk». Это единственная память, используемая high_impact. Размер hunk настраивается при помощи #define ALLOC_SIZE. Из этого hunk можно распределять память двумя способами:

  1. В начале hunk с нарастанием вверх находится bump-аллокатор (также называемый «arena»), который содержит все ассеты и другие необходимые для игры данные, сущности и текущую сцену.
  2. В конце hunk с нарастанием вниз находится временный аллокатор, ведущий себя как malloc() и free(). Он используется в качестве временного хранилища при загрузке ассетов, например, для распаковки изображения до передачи пикселей GPU.

У bump-аллокатора есть несколько «отметок высокого уровня воды», и он в определённые моменты автоматически сбрасывается до них. Благодаря этому нам никогда не нужно явным образом выполнять free() части bump-распределённой памяти. Он ведёт себя немного похоже на пулы autorelease в экосистеме Apple. Концептуально это выглядит так:

game {
    scene {
        frame {}
    }
}

  1. Всё, что было распределено до настройки первой сцены, будет освобождено только после завершения программы.
  2. Всё, что было распределено во время scene.load(), будет освобождено после завершения сцены.
  3. Всё, что распределяется в процессе работы сцены, будет освобождено в конце кадра.

Упрощение здесь заключается в том, что мы вызываем load() для каждого типа сущностей на этапе 1, потому что заранее не знаем, какие сущности могут использоваться в сцене. Данные уровня и всё остальное, что потребуется только для текущей сцены, вызываются на этапе 2, а всё то, что нужно только для логики текущего кадра — на этапе 3.

Кроме того, можно отдельно обернуть код в дополнительный контекст распределения:

alloc_pool() {
    void *result = memory_intensive_computation();
    do_something(result);
}

Это будет просто сокращённой записью такого кода:

bump_mark_t mark = bump_mark();
void *result = memory_intensive_computation();
do_something(result);
bump_reset(mark);

В alloc.h есть дополнительная информация о системе.

Редактор уровней


Исходный движок Impact имел редактор уровней Weltmeister. Я решил сделать его и частью high_impact. Он по-прежнему написан на JavaScript и использует большую часть первоначальных исходников, но был обновлён с учётом фич современных браузеров.

Видео из моего твита за 8 июля 2024 года, демонстрирующее новый Weltmeister

Weltmeister полностью автономен. Достаточно дважды нажать на weltmeister.html и приступить к созданию уровней. В старые времена Weltmeister требовал для загрузки и сохранения файлов API бэкенда (написанный на PHP или NodeJS). Теперь же благодаря FileSystemAPI мы можем просто запросить доступ к конкретной папке. К сожалению, он пока не полностью поддерживается Safari и Firefox (в частности, функция showDirectoryPicker()), так что потребуется браузер, напоминающий Chrome.

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

Weltmeister считывает файлы исходников на C и собирает из них все типы сущностей. high_impact имеет макросы, которые ничего не делают, но их понимает Weltmeister и меняет внешний вид и поведение сущностей в редакторе.

#define EDITOR_SIZE(X, Y)     // Размер в редакторе. По умолчанию (8, 8)
#define EDITOR_RESIZE(RESIZE) // Можно ли менять размер сущности в редакторе
#define EDITOR_COLOR(R, G, B) // Цвет ограничивающего прямоугольника в редакторе. По умолчанию (128, 255, 128)
#define EDITOR_IGNORE(IGNORE) // Можно ли создавать эту сущность в редакторе

Например, сущность-триггер в Biolab Disaster конфигурируется так:

EDITOR_SIZE(8, 8);
EDITOR_RESIZE(true);
EDITOR_COLOR(255, 229, 14);

Демо-игры


Чтобы доказать, что high_impact действительно можно использовать в качестве игрового движка, я портировал на C две мои игры из Impact.

Откровенно говоря, это было довольно скучной задачей. Нужно было просто «транслитерировать» исходники с JS и заново использовать все готовые ассеты. Я считаю отсутствие сложностей доказательством правильной работы high_impact.

▍ Biolab Disaster


Игра, выпущенная вместе с Impact. Сайд-скроллер с прыжками и стрельбой.


▍ Drop


Крайне простая аркадная игра.


Расширяемость


high_impact устроен как традиционный игровой движок, в котором весь относящийся к игре код аддитивен. То есть вам не нужно изменять исходный код самого движка, но я надеюсь, что он достаточно прост, что вы сможете его менять при необходимости. Экспериментируйте!

Платформа и рендерер high_impact рассчитаны на возможность расширения без необходимости внесения изменений в остальную часть кода. Если пользователей заинтересует high_impact, то я надеюсь увидеть множество разных систем и приветствую пул-реквесты для Vulkan, DirectX и Metal, а может и даже для платформенных бэкендов PSX, N64, Dreamcast и любых других платформ.

Это же C. Он должен работать где угодно.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

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