Пишем 3D-игру весом в 600Кб…
...которая будет работать на первых Android-смартфонах в мире, ретро-компьютерах из 90-х и даже Mac'ах! Часть 1.
Иногда у меня лежит душа просто взять и написать какую-нибудь небольшую игрушку с нуля, без использования готовых движков. В процессе разработки я ставлю перед собой интересные задачки: игра должна весить как можно меньше, работать на как можно большем числе платформ и использовать нетипичный для меня архитектурный паттерн. Недавно я начал писать трёхмерные «танчики», которые должны весить не более 600 килобайт с учетом всех ресурсов и в рамках подробной статьи готов рассказать о всех деталях разработки трёхмерной игры с нуля в 2025 году.
❯ Предисловие
Разработка небольших 3D-игр с нуля — одно из моих любимых хобби. Я люблю прорабатывать архитектуру проекта во всех аспектах, начиная с рендера и заканчивая игровой логикой и редакторами, пусть в большинстве случаев и простыми. В рамках прошлых статей мы уже успели с вами написать несколько забавных игрушек-демок под экзотические платформы: например 3D-леталку с использованием Direct3D6 под видеокарты из 90-х годов
Или даже игру про гонки на девяносто-девятке для КПК Dell Axim X51v и его GPU — PowerVR MBX Lite:
Нередко в комментариях меня спрашивали: «А зачем это всё делать с нуля, если есть десятки готовых игровых движков под самые разные задачи?» — и я всегда отвечал, что условный Unity может быть бесконечно мощным, гибким и крутым в рамках актуальных платформ и технологий, но для гиковских задач он практически не подходит. Сможет ли Unity собрать 3D-игру на PS2 или PSP? Вот то-то же!
Именно поэтому разработка самопальных 3D-игрушек — это что-то близкое к написанию игр для NES, SEGA или ZX Spectrum: возможно не так много кому нужно, но весело для самого создателя! И в качестве своей новой игры, я решил написать трёхмерную вариацию на тему «Танчиков» с Денди, а поскольку я люблю себе ставить определенные цели, я заранее сделал небольшой список «хотелок»:
- Игра должна быть написана на Java и работать как минимум на четырех платформах: Windows, Linux, MacOS и Android. При этом мне очень хотелось запустить свою игру на самых первых Android-смартфонах в мире с ОС версии 1.5-1.6: LG GT540, Motorola Droid, Samsung I7500 Galaxy. А поскольку в Android 1.5 всё ещё используется JDK5 — в качестве побочной фичи игра будет запускаться и на ретро-компьютерах!
- Конечный вес игры не должен превышать 600Кб в сборке для Android. Для ПК я сделал исключение — сам jar-файл весит немного, но вот lwjgl (враппер над OpenGL) занимает почти 600Кб даже после оптимизации ProGuard'ом.
- Геймплей в своей основе должен копировать классическую игру с NES. У нас есть n-уровней, где необходимо отстрелять такое-то число танчиков, чтобы пройти на следующий и не дать уничтожить нашу базу. Просто и понятно!
И запустив IDEA вместо привычного мне NetBeans, я принялся творить...
❯ Основа «движка» — с чего всё начинается?
Статья разделена на несколько подразделов, каждый из которых описывает ту или иную часть нашей игры. Сами по себе игры достаточно комплексные программы, а для самопалов, написанных «с нуля», добавляется ещё и бойлерплейт код по типу графа сцены. Но в целом общая архитектура будет понятна даже новичку!
Разработка 3D-игры с нуля начинается с проектирования архитектуры и разработки перефреймворка-недодвижка, который должен упростить написание игровых систем. В моём случае это таймер, планировщик задач на главном потоке, рендер 3D-графики, менеджер звуков, ресурсов, примитивный граф сцены и что-то типа математической библиотеки (векторы с cross/dot/length, а также 4x4 матрицы с самыми типичными представлениями).
Ради оптимальной производительности я сразу же решил для себя использовать «свой» кодстайл и определенные практики вместо общепринятых в Java:
- Максимальная экономия на аллокациях и использовании динамической памяти. Дело в том, что абсолютно все объекты в Java создаются в куче и в отличии от нативных языков или .NET, как класс отсутствует value-типы структур, которые можно было бы создать на стеке. Таким образом, у многих игр даже сложение двух векторов провоцирует аллокацию... а представьте, если этих аллокаций сотни на каждый кадр? Сборщик мусора вам точно не скажет спасибо. Если на ретродесктопе возможно обойтись небольшим дропом кадров, то на первых Android-смартфонах можно добиться чуть ли не фризов! Не верите? Оригинальный Minecraft на Pentium 4 вам в пример!
- Использование глобальных полей вместо геттеров/сеттеров. Геттеры/сеттеры — хороший паттерн, позволяющий не выстрелить себе в колено, но любой вызов метода в Java — это совсем небольшой, но всё же оверхед. Поэтому какой смысл дёргать геттер, если можно напрямую использовать поле объекта, когда того позволяет ситуация?
- Для небольших операций допускается использование анонимных классов. Промисы, аниматоры по типу FadeIn/FadeOut, загрузка уровней — почему бы не сделать их «частью» соответствующих методов?
Основным объектом фреймворка является класс Runtime, который содержит в себе ссылки на остальные подсистемы. Сам рантайм ничего не знает о платформе, на которой он работает и общается с системными функциями с помощью специального интерфейса Platform, который реализует минимально-необходимый функционал: логирование, доступ к файлам и ссылки на системные модули — в том числе и Graphics.
public interface Platform {
String getName();
Graphics getGraphics();
Input getInput();
SoundManager getSoundManager();
void log(String fmt, Object... args);
void logException(Throwable exception);
InputStream openFile(String fileName) throws IOException;
void requestExit();
}Все платформозависимые подсистемы и сам Runtime создаёт так называемый порт: в случае PC это класс Context, а в случае Android — MainActivity. Помимо создания ключевых объектов, порт занимается организацией главного цикла обработки сообщений и пробросом событий в фреймворк с помощью соответствующих коллбэков. На практике это выглядит так:
public void run() {
log("Starting main lo
op"); Runti
me.init(); while(!Display.isCloseRequ
ested()) { Display.processM
essages(); Runtime.Graphics.setViewport(Display.getWidth(), Display.get
Height()); Runtime
.update(); Runti
me.draw();
try { Display.swap
Buffers(); } catch (LWJGLExce
ption e) { log("SwapBu
ffers failed&
quot;);
} } Runtime
.releaseResources(); log(
"Window is closed"); }Так достигается высокая гибкость фреймворка. При необходимости можно сделать мультирендер с поддержкой разных версий OpenGL, добавить прозрачную поддержку бандлов с ресурсами и даже встроить рендер в чужое окно (например редактор уровней)!
Runtime зависит от класса Game, который занимается менеджментом состояния игры: от обработки менюшек, до загрузки уровней и вызова апдейтов/отрисовки для объекта World:
public Game(Runtime runtime) {
Runtime = runtime;
world = new World(runtime);
}
public void init() {
Runtime.Platform.log("Initializing ga
me"); loadingResult = WorldLoader.Instance.load(Runtime, world
, &qu
ot;test"); } pu
blic void update() { if(loadi
ngResult.isDone()) { if(!loadingRe
sult.isSuccessful()) throw new RuntimeException("Loading task canc
elled due to exc
eption"); els
e
world.update();
} } public void draw() { if(loadingResult.isDone() &&
loadingResult.isSucces
sful(
)) world.draw(); }
public void beforeClose() { }Загрузка ресурсов и уровней в игровых движках — тема для отдельной статьи. В своём велосипеде я реализовал асинхронную загрузку за счет стандартного тредпула в Java: загрузчик оборачивает Future в класс-враппер, воркер в процессе загрузки рапортует врапперу об изменениях, а основной поток параллельно показывает окошко с прогрессом. Если воркер кидает исключение, обработчик в стандартном классе FutureTask его перехватывает и выбрасывает ExecutionException в основном потоке, позволяя показать сообщение об ошибке.
public static AsyncResult start(final Runtime runtime, final LoadingWorker worker, String name) {
if(name == null)
throw new NullPointerException("Attempt to start unnamed loading thre
ad"); if(worke
r == null) throw new NullPointerException("Worker can't be null f
or thread " + name); final AsyncResult res = new As
yncResult(runtime, name);
worker.o
nBeforeLoad(res); res.future = execServic
e.submit(new Runnable
() { @Override
public void run() { runtime.Platform.log("Started loading thr
ead %s", res.getThreadName());
worker.onLoad(res); runtime.Platform.log("Loading thread %s successful
ly completed
job",
res.getThreadName()
); } }); return res; }Многие движки не умеют в потокобезопасность, когда речь заходит о выгрузке геометрии или текстур на GPU. В современных графических API проблем с этим нет, но вот в OpenGL и старых версиях D3D это та ещё боль, поэтому фактическая загрузка ресурсов (минуя I/O часть и обработку входного файла) происходит в основном потоке. Для этого я реализовал отдельный планировщик задач, эдакий минимальный аналог Handler в Android. Когда загрузчик текстуры подготовил массив пикселей, он ставит в очередь задачку с выгрузкой данных на GPU и не ожидая завершения возвращает управление потоку загрузки.
runtime.Scheduler.runOnMainThreadIfNeeded(new Runnable() {
@Override
public void run() {
for(int i = 0; i < mipCount; i++)
ret.upload(mipLevels[i].Buffer, mipLevels[i].Width, mipLevels[i].Height, format == FORMAT_PALETTE ? FORMAT_RGB : format);
}
});Ну и какой же игровой фреймворк обходится без менеджера ресурсов на слабых ссылках, который позволяет загрузить текстуру или модель только один раз и использовать её во множестве игровых объектов. Например, игра спавнит 20 танчиков с одинаковой 3D-моделью, но фактическая загрузка геометрии и текстуры произойдет только один раз: при первом вызове getMesh и getTexture. Когда приходит время работы сборщика мусора, он ищет неиспользованные ресурсы и вызывает у них финализатор:
private Object getNamedObject(String name, Class expectedClass) {
if(loadedObjects.containsKey(name)) {
WeakReference weakRef = loadedObjects.get(name);
Object obj = weakRef.get();
if(obj == null) {
runtime.Platform.log("[Resources] Object '%s' was freed previously. Reloading..."); // TODO: Implement weak referenc
es removal over time
retur
n null; } if(obj.getClass
() != expectedClass) throw new ClassCastException("Object of name " + name + " is instance of " + obj.getClass().getSimpleName() + ", but getNamedObject
expected " + expe
ctedClass
.getSimpleName());
return obj; } return null; } priva
te void addObjectToPool(String name, Object obj) { loadedObject
s.put
(name, new WeakReference<Object>(obj));
} public Texture2D getTexture(String name) { Texture2D tex =
(Texture2D) getNamedObjec
t(name, Texture2D.class); if(tex == null) {
tex = TextureLoader.load(runt
ime, name
); addOb
jectToPool(name, tex); } return tex; }В общих чертах архитектура фреймворка понятная — по сути это «классика» проектирования широкоспециализированных игровых движков. Но ведь читатель, привыкший к познавательным статьям с научпоп-уклоном уж точно пришёл сюда не за разговорами об архитектуре, поэтому предлагаю перейти к первой практической части — рендерере!
❯ Рендеринг 3D-графики
Графический движок — один из самых первых модулей, которые реализуют в самопальных играх. В нашем случае он будет достаточно примитивным и использовать Fixed-Function Pipeline. Поскольку Android вплоть до версии 2.2 не поддерживал OpenGLES 2.0, мы остаёмся без поддержки шейдеров и наслаждаемся самым простым функционалом: трансформация геометрии без аппаратного морфинга/скиннинга, вершинное освещение с ограничением в 8 источников на один вызов отрисовки и практически полная невозможность реализации нормальных теней. Зато ретро-лук и ностальгия читателей, которые писали игрушки с glBegin/glEnd в нулевых, обеспечены!
Любой рендер начинается с инициализации контекста и установки базовых рендерстейтов. Это сейчас на каждую группу стейтов есть свои объекты и для каждого материала можно назначить свои параметры отрисовки, а в те годы необходимо было плотно следить за контекстом и если где-то что-то забыл вернуть в изначальное состояние — картинка начинала артефачить, не говоря уже о внезапных крашах, если включить какой-нибудь NORMAL_ARRAY и не передать на него указатель! А уж как хорошо OpenGL поддерживали «встройки» от Intel...
context.log("Context version: %s", glGetString(GL_
VERSION));context.log("Graphics card: %s", glGetS
tring(GL_RENDERER));context.log("Chec
king extension support");String extensions
= glGetString(GL_EXTENSIONS);requireExtension(extension
s, "GL_SGIS_generate_m
ipmap");orthoMatrix
= new Matrix();// Initialize basic st
ateglEnableClientState(GL_VERTEX_ARRAY);glEn
ableClientState(GL_TEXTURE_COORD_ARRA
Y);glEnableClientState(GL_NORMAL_ARRAY);glBlendFun
c(GL_SRC_ALPHA, GL_ONE
_MINUS_SRC_ALPHA);glEna
ble(GL_LIGHTING);glEn
able(GL_CULL_FACE);glCullFace(GL_FRONT);matrixBuf
fer = ByteBuffer.allocateDirect(4 * 16);matr
ixBuffer.order(ByteOrder.nativeOrder());m
atrixBuf = matrixBuffer.asFloatBuffer();Canvas = new Canvas(this);Чтобы что-то нарисовать на экране, нужно это что-то сначала подготовить. Поскольку игра у нас маленькая во всех аспектах, я написал утилиту для конвертации моделей и текстур в собственные форматы. Формат моделей простой: буквально сами меши и их индексы, даже компрессии с вершинами в байтах как в Quake нет:
// Export SubMesh struct
for(Map.Entry<String, ArrayList<Vertex>> subMesh : meshCollection.entrySet()) {
output.writeUTF(subMesh.getKey());
System.out.println("Building indic
es"); buildIndices(subMesh.getValue(), verts,
indices); output.writeInt(vert
s.size()); output.writeInt(indice
s.size()); for(Vertex vert
: verts) { output.writeFloa
t(vert.X); output.writeFloa
t(vert.Y); output.writeFloa
t(vert.Z); output.writeFloat
(vert.NX); output.writeFloat
(vert.NY); output.writeFloat
(vert.NZ); output.writeFloa
t(vert.U); output.writeFloa
t(ver
t.V); } for(Short i
: indices) output.writ
eShort(i); vert
s.clear(); indice
s.clear();}
А вот с текстурами пришлось подумать. Дело в том, что типичная 16-и битная текстура 256x256 занимает целый 131 килобайт памяти, что для наших целей слишком много. Умные дяди из S3 Graphics ещё в конце 90-х придумали формат S3TC (сегодня известный как DXT) во времена 4Мб видеокарт, который позволял сжать 16 пикселей в 8 байт, но мобильные GPU кроме Tegra его не поддерживают, а распаковывать его «на лету» не так то просто.
Поэтому обратившись к опыту предков, я решил использовать 4х-битные палитровые текстуры, которые могут иметь не более 16 цветов. На первый взгляд кажется, что 16 цветов будет слишком мало для большинства текстур и написав простенький алгоритм квантования, который сортирует самые частые цвета в изображении и формируют по ним палитру, я пришёл к такому же выводу:
Однако Photoshop умеет преобразовывать изображения в палитровые с минимальной потерей качества! Текстура тех же размеров, визуально ничем не отличающаяся от RGB565, будет весить всего 32 килобайта, а если далее её пожать Deflate'ом — 24 килобайта. Текстура 128x128 так вообще ПЯТЬ килобайт — это точно вин!
Единственный момент — ни один мобильный GPU не поддерживает палитровые текстуры, так что «под капотом» они всё равно преобразуются в RGBA с колоркеем.
if(palette.length / 3 == 16) {
// 4-bit palette unpacking
for (int j = 0; j < (width * height) / 2; j++) {
int pixel1 = (buf[j] & 0xF) * 3;
int pixel2 = ((buf[j] >> 4) & 0xF) * 3;
mipLevels[i].Buffer.put(palette[pixel1 + 2]);
mipLevels[i].Buffer.put(palette[pixel1 + 1]);
mipLevels[i].Buffer.put(palette[pixel1]);
mipLevels[i].Buffer.put((byte) 255);
mipLevels[i].Buffer.put(palette[pixel2 + 2]);
mipLevels[i].Buffer.put(palette[pixel2 + 1]);
mipLevels[i].Buffer.put(palette[pixel2]);
mipLevels[i].Buffer.put((byte) 255);
}
} else {
for (int j = 0; j < width * height; j++) {
int paletteSample = (buf[j] & 0xFF) * 3;
mipLevels[i].Buffer.put(palette[paletteSample + 2]);
mipLevels[i].Buffer.put(palette[paletteSample + 1]);
mipLevels[i].Buffer.put(palette[paletteSample]);
mipLevels[i].Buffer.put((byte) 255);
}
}Следующий кирпичик в рендерере — система материалов, которая задаёт как будет выглядеть тот или иной объект на экране. В больших движках она достаточно комплексная и привязана к константам в шейдерах — юниформах, а также многопроходному рендерингу. В FFP же материал содержит в себе настройки рендерстейтов «как есть», а также ссылки на текстуры:
public String Name;
public Texture2D Diffuse;
public Texture2D Detail;
public float R;
public float G;
public float B;
public float A;
public float AlphaTestValue;
public boolean DepthWrite;
public boolean DepthTest;
public boolean AlphaBlend;
public boolean AlphaTest;
public boolean Unlit;
По итогу, вся система материалов выглядит так. И поверьте, без кэширования стейтов в профайлер лучше не заглядывать:
setState(GL_DEPTH_TEST, material.DepthTest);
glDepthMask(material.DepthWrite);
setState(GL_ALPHA_TEST, material.AlphaTest);
setState(GL_BLEND, material.AlphaBlend);
setState(GL_TEXTURE_2D, material.Diffuse != null);
setState(GL_LIGHTING, true);
if(material.AlphaTest)
glAlphaFunc(GL_LESS, material.AlphaTestValue);
if(material.Diffuse != null) {
glClientActiveTexture(GL_TEXTURE0);
material.Diffuse.bind();
} else {
glBindTexture(GL_TEXTURE_2D, 0);
}
if(material.Detail != null) {
glClientActiveTexture(GL_TEXTURE1);
material.Detail.bind();
}
for(int i = 0; i < LIGHT_COUNT; i++) {
int light = GL_LIGHT0 + i;
if(lightSources[i] == null) {
setState(light, false);
} else {
setState(light, true);
vectorBuf.put(material.R);
vectorBuf.put(material.G);
vectorBuf.put(material.B);
vectorBuf.put(material.A);
vectorBuf.rewind();
glMaterial(GL_FRONT_AND_BACK, GL_DIFFUSE, vectorBuf);
vectorBuf.put(lightSources[i].Position.X);
vectorBuf.put(lightSources[i].Position.Y);
vectorBuf.put(lightSources[i].Position.Z);
vectorBuf.put(lightSources[i].IsDirectional ? 0 : 1);
vectorBuf.rewind();
glLight(light, GL_POSITION, vectorBuf);
}
}
А вот и результат её работы! Уровень графики примитивный, но в целом очень сильно напоминает shareware-игры из нулевых... Ах, ностальгия!
❯ «Граф» сцены, система компонентов и загрузка уровней
Для того чтобы игру было легко модифицировать и поддерживать, необходимо сразу продумать грамотную архитектуру игровых объектов. Кто-то ограничивается классической концепцией Entity (как в Quake, Half-Life и многих других играх), кто-то добавляет к Entity систему компонентов (как в Unity), а некоторые пихают модный ECS куда ни попадя. Я решил остановиться на паттерне Entity-Component, однако в отличии от той же «юньки», где напрямую унаследоваться от GameObject нельзя, в моей реализации основную логику задают именно сами GameObject'ы, оставляя на компонентах рендеринг и данные по типу коллизий:
public abstract class GameObject {
Vector<Component> components;
public void onCreate() {
}
public void onUpdate() {
for(Component c : components)
c.onUpdate();
}
public void onDraw(Graphics graphics, Camera camera, int renderPassFlags) {
for(Component c : components) {
c.onDraw(graphics, camera, renderPassFlags);
}
}
public void onDestroy() {
}
public void loadResources() {
}
public void onLateUpdate() {
}
}Структура уровня выстраивается из таких игровых объектов как из кирпичиков. Например, StaticMesh представляет из себя статичную модель на сцене, а унаследованный от него StaticObject добавляет к нему коллизию и может запросить «запекание» однообразной геометрии в один батч. При этом уровни не ограничены каким-то широко специализированным форматом с сериализацией всех полей: необходимо писать кастомные загрузчики, специфичные для той или иной игры.
В случае танчиков — формат текстовый, дабы можно было легко изменять уровни как в блокноте, так и в блендере:
# Level format:
# Tags:
# Sky - Skysphere texture name
# Weather - One of the supported weathers (Sunny, Rainy, Thunderstorm)
# TaskScript - Script-class with map tasks
# Objects:
# <Class> <X, Y, Z> <RX, RY, RZ> <Has collision: 0 - No collision, 1 - Has collision> <Variadic> (depends from class)
Tags:
Sky sunny
Weather sunny
TaskScript com.monobogdan.game.tasks.GenericTask
TargetTankCount 15
DifficultyMultiplier 1.0
Objects:
StaticObject -3.02 1.00 -27.86 0 0 0 1 crate.mdl brick.tex
StaticObject -5.02 1.00 -27.86 0 0 0 1 crate.mdl brick.tex
❯ Игровая логика
Теперь у нас есть всё необходимое для написания самой игры! Начинаем с игрока. По сути, танчик должен уметь ездить в одну из выбранных сторон, останавливаться, если мы врезаемся в стенку и стрелять.
chooseDirection(x, y);
// Calculate forward vector for desired rotation dir
forward.calculateForward(rotationDir);
collisionHolder.Min.set(forward.X - mesh.BoundingMax.X, forward.Y - mesh.BoundingMax.Y, forward.Z - mesh.BoundingMax.Z);
collisionHolder.Max.set(forward.X + mesh.BoundingMax.X, forward.Y + mesh.BoundingMax.Y, forward.Z + mesh.BoundingMax.Z);
boolean canMove = Rotation.compare(rotationDir, 5.0f);
tmpVector.set(Position);
if((x == -1.0f || x == 1.0f) && canMove) {
Position.X += x * ACCELERATION_FACTOR;
canMove = false; // Single axis at time
}
if((y == -1.0f || y == 1.0f) && canMove)
Position.Z += y * ACCELERATION_FACTOR;
// Check collision with walls
if(collisionHolder.isIntersectingWithAnyone(CollisionHolder.TAG_STATIC) != null) {
Position.set(tmpVector);
desiredPosition.set(tmpVector);
}
Однако классическому "контроллеру" танчика с NES не хватает плавности, да и сами карты у нас заметно больше NES'овских. Для решения этой задачки можно использовать Easing-функции, где самая простая - линейная интерполяция. Используя её в качестве экспоненциального затухания, мы можем сделать достаточно плавную камеру:
final float EASE_SPEED = 0.04f;
forward.Z = -10;
tmpVector.set(Position);
tmpVector.add(forward);
tmpVector.Y = 20;
targetRotation.X = 75 + (-velocity.Z * 5);
targetRotation.Y = velocity.X * 15;
World.Camera.Position.lerp(World.Camera.Position, tmpVector, EASE_SPEED);
World.Camera.Rotation.lerp(World.Camera.Rotation, targetRotation, EASE_SPEED);
И по итогу, на данный момент времени мы имеем следующий результат:
❯ Заключение
Вот такая статья о разработке 3D-игры с нуля у нас с вами получилась. Прошлые статьи в этой рубрике я писал в стиле туториала, но в этой я решил рассмотреть конкретные кейсы и архитектурные решения. И может она не настолько простая и понятная, как статья про разработку «самолетиков», думаю своего читателя она точно нашла! Если вам интересно, с кодом можно ознакомиться на моём Github (пока ещё очень сырая демка, там ещё есть что порефакторить и переписать).
