
Catlantean 3D — это хобби-проект, который я неспешно пишу в своё свободное время уже больше года. В следующем году я планирую выпустить его в Steam.
Я хочу создать завершённый, готовый к выпуску шутер от первого лица при помощи методик, характерных для начала 90-х, при этом позволив себе роскошь пользования современным компилятором и слоем абстракций платформы.
Всё это означает, что я по глупости своей наложил на себя следующие ограничения:
игра должна быть сделана полностью с нуля, в том числе и ассеты,
весь рендеринг должен выполняться вручную,
всё аудиомикширование должно выполняться вручную,
целевое разрешение: 320x240,
всего 256 цветов,
числа с плавающей точкой разрешены, но поведение должно быть одинаковым на всех платформах:
я решил использовать в игровой логике числа с фиксированной запятой, чтобы гарантировать детерминированное поведение, и числа с плавающей запятой при рендеринге, потому что для него детерминированность не важна,
это должна быть готовая, качественная игра, в которую интересно играть (не техническое демо),
слой абстракций платформы разрешён, но я должен сделать вид, что он очень ограничен (в разумных пределах):
буфер кадров для записи в него пикселей,
ввод с клавиатуры и мыши,
аудиобуфер для записи в него сэмплов,
ввод-вывод файловой системы,
никакого нейрослопа.
Если это звучит для вас разумно, то именно потому, что так всё и есть.
В этом посте я поговорю о том, что обычно упускают в блогах разработчиков: о создании ассетов (графики).
Примечание: всё показанное ниже находится в процессе работы и может поменяться в готовой игре.
Рендеринг и палитра
VGA-графика
Mode 13h в VGA-оборудовании был знаменитым 256-цветным графическим режимом с разрешением 320x200, который определил стиль целого поколения игр для PC. С точки зрения программиста, он был замечательно простым: в нём присутствовал линейный буфер кадров, в котором каждый пиксель описывался одним байтом, индексирующим палитру из 256 цветов.
Если вам нужен был пиксель, вы записывали байт по конкретному адресу; вот и всё, никаких шейдеров или VRAM и чего-то подобного.
По одному байту на пиксель, и этот байт используется как индекс палитры, содержащей истинные RGB-значения, которые отрендерятся на экране. Это накладывает на разработчиков интересные ограничения: при создании графических ассетов для современных игр можно использовать в картинке миллионы цветов, но если каждый пиксель на экране может быть только одного из 256 цветов, создание ассетов становится совершенно иной задачей, потому что выбор каждого цвета должен быть продуманным и точным.

Примерами правильной реализации такого подхода можно считать игры наподобие Doom и Duke Nukem. В их графике присутствует чёткость, возникающая благодаря этим техническим ограничениям, а не вопреки им. Ограничения вынуждают делать осмысленный выбор, а результат осмысленного выбора обычно выглядит хорошо.
Catlantean 3D — это попытка воссоздать похожий стиль, но с одним исключением: я решил выбрать что-то ближе к VGA Mode-X, разрешение которого равно 320x240. Причина этого в том, что если отображать 320x200 на дисплее 4:3, то пиксели окажутся не квадратными! Это было бы аутентичнее, но, исходя скорее из личных предпочтений, чем по объективным причинам, я решил пойти этим путём.
Как же создавать графику, отвечающую этим ограничениям?
Палитра
Всё начинается с 768 байт, тщательно выбранных долгим путём проб и ошибок.

В основном при выборе конкретно этих цветов я руководствовался следующими причинами:
один зарезервирован под прозрачность (ярко-розовый),
один зарезервирован под чистый белый,
один зарезервирован под чистый чёрный,
очевидно, мне понадобится много крови, поэтому и оттенки красного,
оттенки зелёного и синего, потому что у меня будут красные, зелёные и синие ключи и соответствующего цвета двери,
антуражем игры будет Кэтлантис — страна-пародия, напоминающая Древний Египет (ведь там поклонялись кошкам), поэтому, очевидно, понадобится множество оттенков пустыни (жёлтых и коричневых),
множество серого, потому что в мире будет много технического оборудования (Кэтлантис оккупирован кибернетическими человеко-псами),
несколько оттенков бежевого, чтобы разбавить монотонность серого и для использования в качестве более тёплых замен при затемнении (подробнее об этом ниже),
остальная часть палитры будет заполнена по ходу создания текстур, в основном субъективно.
Палитра появилась на свет не за раз; мне приходилось много раз возвращаться к ней в процессе создания ассетов, тестирования и повторных итераций.
Вот примеры спрайтов и текстур из реальной игры:



Цветовая карта
В движке Catlantean 3D используется традиционный рейкастинг. Карта состоит из тайлов одинакового размера; некоторые из них представляют стены, другие — просто пустоты с полом и потолком. При рендеринге карты рендерер использует для каждого столбца пикселей на экране алгоритм DDA, обходя карту тайлов и определяя, где он упирается в геометрию карты; на основании этого на экране рендерится столбец стены с соответствующей текстурой, сэмплированной из координат. Потом горизонтальными растровыми строками рендерятся полы и потолки, заполняя остальную часть экрана.
Рейкастинг уже разбирали в куче других блогов и статей, поэтому не буду рассматривать его подробно; но мне хотелось бы поговорить о его самом упускаемом из виду аспекте: освещении.
Если мы собираемся рендерить игровой мир всего одной палитрой без каких-либо спецэффектов, то в результате получим нечто плоское и не особо впечатляющее:

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

В современном рендерере с аппаратным ускорением это можно тривиально реализовать при помощи шейдера: на основании расстояния до вершины мы умножаем его вектор цвета на коэффициент с плавающей запятой, получив в итоге затенённый цветовой вектор.
Но как достичь подобного в рендерере на основе палитры? В нём нет концепции цвета, лишь индексы в палитре. Если бы нам нужно было найти более тёмный оттенок определённого цвета, то пришлось бы пройти по палитре и найти цвет, отвечающий критерию «более тёмный». Это было бы чересчур: мы не можем обходить всю палитру для каждого пикселя экрана, это было бы слишком медленно.
Вместо этого мы можем выполнить предварительную обработку, чтобы обеспечить быстрый поиск цвета на основании расстояния в среде исполнения.
Можно расположить нашу палитру в одну строку:

а затем выбрать количество уровней оттенков (в моём случае 32); это означает, что каждому цвету требуется 31 более тёмный вариант; все они будут находиться в палитре. Мы знаем RGB-значения каждого цвета, поэтому из этого и индекса оттенка можно определить ближайший цвет этого оттенка:
// Первый индекс оттенка (0) - это исходный цвет.
float darkening_factor = (32 - shade_index) / 32.0f;
target_darker_color.r = current_color.r * darkening_factor;
target_darker_color.g = current_color.g * darkening_factor;
target_darker_color.b = current_color.b * darkening_factor;Но такого цвета может и не существовать в палитре, поэтому нам нужно выполнить цикл по палитре и найти ближайший к нему цвет.
В процессе разработки определение слова «ближайший» для меня изменилось: поначалу я просто брал в качестве величины евклидово расстояние, но проблема такого подхода заключалась в том, что почти все цвета склонялись к серому из-за самой математики. В некоторых старых играх применялось евклидово расстояние, но на мой взгляд, выглядело это не очень красиво. Не могу объяснить, почему, но множество тёмных оттенков казалось холодным и безжизненным. Поэтому я конвертировал свои цвета в цветовое пространство Oklab и воспользовался его формулой перцептивного расстояния, которая ближе к тому, как люди воспринимают различия в цветах. Кроме того, я применил небольшой сдвиг к более тёплым оттенкам, когда цвет становился темнее (это популярная в пиксель-арте концепция, называющаяся «hue shifting»). Обычно это необязательно, но так игра просто стала выглядеть чуть лучше.
Как я определял, что «лучше» в этом случае? Понятия не имею, просто по ощущениям. Бесит, правда? Субъективное восприятие рационализировать сложно.
Что ж, вернёмся к алгоритму...
По сути, для каждого цвета мы создаём столбец, обозначающий оттенки этого цвета. В результате мы получили 2D-матрицу индексов палитры, называющуюся цветовой картой (colormap). Обратите внимание, что градиенты цветовой карты неидеальны, потому что мы по-прежнему ограничены цветами палитры:

Итак, теперь определение более тёмного оттенка цвета N на основании расстояния становится тривиальной задачей.
Пусть у нас есть индекс строки цветовой карты (то есть уровень оттенка) на основании расстояния:
colormap_row = 32 * fragment_distance / view_distanceМы выбираем N-ный элемент строки, принадлежащий этому оттенку — это и есть индекс палитры затенённого цвета N.
Вуаля, задача выполняется за O(1).

Кроме того, мы вычисляем индекс строки цветовой карты не для каждого пикселя, благодаря чему затраты ещё больше снижаются:
при рендеринге стен мы делаем это только раз на один столбец пикселей экрана, потому что стены вертикальны, поэтому каждый пиксель столбца будет иметь одно и то же расстояние от камеры,
при рендеринге полов это происходит один раз для каждой строки экран, потому что они горизонтальны, а значит, каждый пиксель в строке имеет одно и то же расстояние от камеры,
только один раз для каждого спрайта, потому что это абсолютно плоские билборды (изображения, всегда перпендикулярные игроку), в которых каждый пиксель имеет одно и то же расстояние от камеры.
Итак, мы выполняем вычисления индекса строки цветовой карты по 320 раз для стен, не более 240 раз для полов и потолков и по одному разу для каждого видимого спрайта (благодаря рейкастингу без лишних затрат обеспечивается усечение по области видимости). Всё это малозатратно, а результат оказывается великолепным.
Подобный подход использовался в Doom и многих других играх.


Создание графических ассетов
Текстуры и спрайты в Catlantean 3D можно разделить на три категории:
Предварительно отрендеренные спрайты: 3D-модели, созданные в Blender и отрендеренные в текстуры.
Нарисованные вручную спрайты и текстуры.
Процедурно сгенерированные текстуры: созданные специальными скриптами на Python с использованием сочетания нарисованной вручную графики.
Предварительно отрендеренные спрайты
У меня есть постоянная работа и достаточно активная жизнь, поэтому время на создание игры ограничено. Поэтому я хотел минимизировать время многократной переделки сложных спрайтов, требующих анимаций. У меня редко получается сделать всё правильно с первой попытки, поэтому, естественно, нужны будут дополнительные итерации, а если надо вносить изменения во множество кадров анимации, этот процесс усложняется.
Эффективнее оказалось создавать спрайты в Blender в виде 3D-моделей, добавлять им риг и анимировать, а затем рендерить их в виде последовательности текстур при помощи специальных скриптов на Python, использующих Python API Blender. Благодаря этому при повторных итерациях достаточно было вносить изменения в модель, после чего скрипты рендеринга делали всё остальное, экономя мне кучу времени.


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


После создания изображения оно прогонялось через специальный скрипт на Python, выполнявший дискретизацию в палитру, создавая изображение с однобайтными значениями пикселей, используемое движком. Для каждого пикселя исходного изображения скрипт находит ближайший цвет в палитре (с точки зрения восприятия, потому что Oklab) и применяет к этому пикселю индекс этого цвета. Массив индексов вместе с размерами упаковывается в очень простой формат TEX, которым пользуется игра.

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


Спрайты врагов рендерятся особым образом. У спрайта может быть множество анимаций, и каждая анимация должна иметь кадры для каждого из восьми направлений, в которых может смотреть спрайт. Поэтому для каждой анимации (ходьба, стрельба, умирание и так далее), скрипт на Python, использующий API Blender, поворачивает спрайт, рендерит все кадры анимации, снова поворачивает спрайт и так далее [прим. пер.: здесь автор, скорее всего, имеет в виду 3D-модели]. Спрайты сохраняются с определённым форматом имени, в котором указывается имя спрайта, название действия, направление и индекс кадра:

Такой подход полезен тем, что мне не нужно хранить отрендеренные спрайты в репозитории: они добавлены в .gitignore. При работе на другом компьютере я просто запускаю скрипт компиляции, который рендерит все модели и генерирует спрайты. Это довольно быстро: 15 моделей обрабатываются на RTX 3070 примерно за десять секунд.
Создаваемые вручную спрайты и текстуры
На раннем этапе разработки я создал эту голову, приблизительно напоминающую кошачью, взяв текстуру моей кошки Вилко; я использовал её в качестве лица на панели состояния. В конце концов, зачем рисовать что-то вручную, если Blender способен рендерить настолько реалистичные изображения?

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

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


Ту же логику я применил и большинству подбираемых предметов: изначально они были отрендеренными, но при таком масштабе композитор Blender не мог стабильно обеспечивать качественных результатов. При работе вручную чёткость и читаемость сильно улучшились.

Вы можете спросить: почему бы просто не увеличить разрешение спрайта? Растеризатор игры ведь сам выполняет масштабирование?
Это сработало бы, но результат выглядел бы ужасно, потому что масштаб пикселей перестал бы быть согласованным. Мы подсознательно ожидаем, что пиксели в любой строке или столбце экрана остаются того же размера при приближении и отдалении. Если масштаб пикселей будет варьироваться от спрайта к спрайту, то это правило больше не соблюдается, и это выглядит неестественно. Наверно, это одна из самых важных причин того, что многие моды с заменой ассетов или халтурные инди-игры выглядят плохо: разработчики просто запихивают туда ассеты с разным масштабом, которые не сочетаются друг с другом.
Одна единица длины в мире Catlantean 3D равна 64 пикселям, и каждый спрайт создаётся относительно этого масштаба. Так что если нам нужен спрайт, который в четыре раза меньше единицы длины мира, то он должен иметь высоту 64/4=16 пикселей.
HUD
HUD и его элементы почти полностью нарисованы вручную. В том числе:
панель состояния внизу экрана,
различные панели переходов и экраны,
шрифты.
Вот, например, как выглядит экран результатов в конце уровня (работа над ним ещё продолжается):
HUD рисуется вручную в том смысле, что всё осмысленно размещается мной, но для получения результатов я активно использую эффекты слоёв и композитинг Affinity Photo, а не рисую всё с нуля. Применяются следующие эффекты:
3D-вид плоских поверхностей (эффекты рельефности),
генерация и накладывание шума для более брутального внешнего вида,
оверлеи цвета, режимы смешения, эффекты сияния,
изменение расположения элементов, потому что я часто всё переделываю.
Обычно я сначала работаю в Truecolor в Affinity Photo. Обратите внимание на слои — большинство из этих элементов в буквальном смысле являются одноцветными прямоугольниками с применёнными спецэффектами и магией смешения.

Affinity Photo — отличная программа, однако экспортированные из неё изображения содержали странные артефакты, скорее всего, вызванные сглаживанием, которое я не смог отключить. Или не смог разобраться, как его отключать. Поэтому Affinity Photo не совсем идеально подходит для попиксельной работы, а значит, мне пришлось выполнять ещё один проход в Aseprite, включавший в себя следующие аспекты:
попиксельно-точный текст,
нарезка графики на части,
рисование по краям элементов, чтобы границы были более чёткими и резкими.

Процедурно генерируемые спрайты и текстуры
Некоторые текстуры слишком просты или специфичны, поэтому приходится рисовать их вручную. Однако многие текстуры Catlantean 3D имеют общую структуру: базовый материал с разной степенью изношенности, загрязнённости и детализации поверхностей. Отрисовка каждого варианта вручную была бы слишком монотонной, поэтому для их генерации я написал скрипты на Python.
Для конвейера генерации требуется множество входных данных:
карта высот, определяющая рельеф поверхности:
на самом деле, сначала генерируется карта нормалей, в которую затем запекаются простое освещение и тени,
карта шума для вариаций,
карта грязи и изношенности,
два базовых цвета,
карта яркости для тех частей, которые сохраняют цвет вне зависимости от других параметров.
Из этих данных скрипт генерирует готовую текстуру, дискретизированную по палитре и готовую для использования в движке. Затем для настройки текстуры достаточно изменения параметров, а не перерисовки пикселей, что позволило существенно сэкономить моё время.

Некоторые примеры сгенерированных текстур:

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

Этим конвейером управляет скрипт на Python. Он получает спрайт, палитру и набор параметров, а затем генерирует серию кадров, которые используются в игровых данных в качестве анимации. Ниже я объясню, как это устроено.
Этап 1: разбиение Вороного
Спрайт разбивается на блоки. Из непрозрачного тела спрайта случайно выбираются K порождающих пикселей, и каждый пиксель привязывается к ближайшему порождающему. Каждая получившаяся ячейка становится одним разлетающимся ошмётком.

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

Этап 3: физика
Каждый блок получает центр тяжести, вектор скорости, направленный от центра спрайта с рандомизированным разбросом, частоту вращения, гравитацию и сопротивление воздуха. Затем выполняется симуляция, использующая все эти параметры. Распознавания коллизий в ней нет, блоки просто останавливаются, когда достигают пола. Система довольно грубая, но вполне подходящая.

Количество блоков, сила взрыва, гравитация, сопротивление воздуха, разброс и глубина ран настраиваются через параметры:
"seed": 295312884,
"frames": 20,
"chunks": 48,
"explode": 3,
"gravity": 1.4,
"drag": 0.22,
"spread": 1.15,
"spin": 9,
"woundDepth": 2,Для получения хорошего порождающего значения нужно немного потрудиться, но это точно быстрее, чем рисовать эти анимации вручную. Та же методика используется и для разрушаемых предметов окружения (цветочных горшков, бочек, ящиков и так далее).
Эта графика, как и предварительно отрендеренные анимации, тоже не хранится в репозитории, повторно генерируется после checkout репозитория. Всё выполняется за считанные секунды.
Предварительно отрендеренные системы частиц
Большинство эффектов частиц нарисовано вручную в Aseprite, но некоторые из них сгенерированы и запечены, подобно ошмёткам: скрипт на Python выполняет симуляцию и создаёт последовательность кадров в PNG, которые затем дискретизируются в TEX. В игре нет системы частиц, работающей в среде исполнения; всё заранее запечено, чтобы кадр рендерился максимально быстро любым программным растеризатором.
Слово «частицы» здесь не совсем подходит, потому что на самом деле конвейер вообще не симулирует частицы. Каждый кадр синтезируется попиксельным вычислением радиального энергетического поля с объединёнными вместе несколькими независимыми слоями:
ядро — мягкий диск, который расширяется в процессе анимации,
лучи — острия вокруг ядра с настраиваемой остротой и длиной; каждый луч получает индивидуальные колебания длины от генератора случайных чисел, поэтому результат выглядит неровным,
кольцо — дополнительная расширяющаяся ударная волна,
шум — значение шума, умноженное на общую энергию, чтобы ровные контуры превратились в рваные.
Накопленная энергия каждого пикселя дискретизируется относительно графика палитры, указываемого в виде параметра скрипта. Каждая строка в палитре считается градиентом от светлого к тёмному, поэтому затенение каждого пикселя выполняется без смешения и расчёта альфа-канала, одной лишь арифметикой индексов палитры. Выше определённого порога пиксели сдвигаются в сторону белого, что придаёт им впечатление раскалённого добела ядра.
Кроме того, поверх иногда добавляется небольшое количество крестообразных искр, которые движутся наружу и со временем затухают.
Анимация поддерживает два режима: одноразовый, нарастающий и затухающий (например, взрыв или вспышка телепорта), и зацикленный, который сэмплирует поле шума по круговому пути, чтобы первый и последний кадры соответствовали друг другу, а цикл был бесшовным (это полезно для циклически меняющихся эффектов, например, плазменных разрядов, энергетических выстрелов и так далее).


Карты
Изначально я редактировал карты в Tiled, это отличный инструмент, пока у тебя не появляются особые требования.
В нём мне не хватало тех концепций, которые требовались в моей игре: отрисовка уровня освещённости для каждой ячейки, флаги и свойства ячеек; изначально я обходил эти проблемы при помощи свойств объектов. Кроме того, при работе нужен был скрипт на Python для преобразования JSON программы Tiled в двоичный формат, используемый движком; этот дополнительный этап требовался лишь для компенсации отсутствия нужных мне возможностей.
Кроме того, у меня была мысль выпустить вместе с игрой и редактор. Не стоило ожидать, что игроки будут устанавливать Tiled, изучать его интерфейс и настраивать скрипты лишь для того, чтобы создать карту. Потерялась бы малейшая вероятность того, что редактором будут пользоваться.
Поэтому я написал собственный. У него есть нативная поддержка рисования уровней освещения, флагов ячеек и всех типов сущностей и свойств, известных игре. После этого разработка стала гораздо более приятной, потому что мне не приходилось больше ограничиваться возможностями Tiled, а после выпуска игры пользователи получат тот же самый редактор, с которым работал и я.

Он не требует никакой настройки, даже можно запускать уровни непосредственно из него:
Да, я понимаю, что иконки панелей ужасны. Именно поэтому я их и сохранил.
Редактор создан при помощи wxPython, который оказался вполне достойным выбором для подобного инструмента. Он подошёл лучше, чем tkinter (который я попробовал сначала), особенно что касается виджетов, обработки событий, компоновки; с ним просто приятнее было работать, а конечный результат выглядел более нативным. Итерации происходили быстро, а структурирование на основе паттерна MVP позволял отделять логику UI от данных карт, а это важно, когда часто меняется и то, и другое (мой формат карт ещё не стабилизировался). Получился хороший баланс между внутренним инструментом разработчика-одиночки и программой, которая будет выпущена для конечных пользователей.
Не всё в редакторе написано на Python. Модель во многом использует мою библиотеку pybast: по сути, это привязки Python к внутренностям движка (с помощью pybind), которые включают в себя:
чтение архива с данными игры,
чтение текстур игры (для отображения),
класс с фиксированной точкой для координат сущностей,
сериализацию.
В основном это нужно для того, чтобы не реализовывать всё это повторно на Python, если уже реализовано на C++. Поэтому движок и его инструментарий образуют маленькую связанную экосистему.
Заключение
Я планирую опубликовать Catlantean 3D примерно в первом квартале 2027 года. Пока я занимаюсь дизайном уровней, добавляю новых врагов и оружие, а также совершенствую игру.
Собираюсь продавать её где-то за $5–8 и намереваюсь выпустить исходный код игры в опенсорс на GitHub, но для получения архива с данными (с графикой, уровнями, звуками, музыкой и так далее) нужно будет её купить, что, как мне кажется, справедливо.
В конце концов, главному герою нужно оплатить его труды.



























