
DOOM, пожалуй, самый известный шутер от первого лица в истории компьютерных игр. Эта игра не только завоевала коммерческий успех, но и заслужила репутацию одной из лучших и наиболее влиятельных видеоигр всех времен. В 1999 году исходный код Doom был выпущен под лицензией GNU General Public License, и с тех пор он был портирован на множество платформ.
Очевидно, что я не первый, кто решил запустить DOOM на Android. Эта статья была вдохновлена проектом Doom‑Android на GitHub, который, в свою очередь, был основан на другом проекте под названием Doom‑Generic. Последний, в свою очередь, базируется на порте fbDOOM, который основан на... (ну вы поняли). Проект Doom‑Android работает хорошо, однако он использует «классический» Android API. Он не поддерживает Jetpack Compose, и я также раньше не видел Doom на современных устройствах Android Wear. Итак, без лишних слов, давайте приступать к портированию!
Я взялся за этот проект для Android Wear просто потому, что это интересно, и не так много людей видели, как на часах работает полноценная 3D‑игра. Однако я также хочу, чтобы проект был доступен и для «стандартного» Android. Таким образом, читатели, у которых нет смарт‑часов, смогут наслаждаться тем же кодом на своих смартфонах.
Структура приложения
Чтобы запустить DOOM в Jetpack Compose, нам предстоит проделать несколько важных шагов:
Связать исходный код DOOM (который был написан на C) с Kotlin, используя JNI (Java Native Interface). Это позволит нам запустить игру и получить данные из ее видеобуфера.
Внести некоторые изменения в исходный код DOOM, чтобы сделать его совместимым со стандартами Android.
Использовать GLSurfaceView и GLSurfaceView.Renderer — компоненты Android, которые будут отображать видеоданные из DOOM.
Наконец, запустить код «реактивно» с помощью Jetpack Compose.
Итак, давайте приступим!
1. JNI (Java Native Interface)
Исходный код DOOM очень старый. Настолько старый, что некоторые читатели, вероятно, еще даже не родились, когда он был написан. В начале файла d_main.h мы можем увидеть строки:
Щ// // Copyright(C) 1993-1996 Id Software, Inc. //
Очевидно, что этот код старше как Kotlin, так и Android, и даже самой Java (первая версия JDK 1.0 была выпущена в 1996 году). Однако, к счастью для нас, есть простой способ запустить код C++ на Android с помощью Java Native Interface (JNI). Эта технология, хотя и не является новой (я видел на форумах вопросы о JNI еще в 2005 году), по‑прежнему прекрасно работает на устройствах Android.
Чтобы запустить DOOM на Android, нам понадобятся как минимум три метода: init, который загрузит игру из WAD‑файла, main, который запустит саму игру, и getFrame, который предоставит нам страницу видеобуфера.
Сначала давайте создадим Kotlin‑класс под названием Doom:
package com.dmitrii.doomsmartwatch class Doom { external fun init(wadData: ByteArray, wadFilename: String) external fun main() private external fun getFrame(screenBuffer: ByteArray) }
Теперь мы можем создать соответствующие методы C++.
Чтобы связать код Kotlin и C/C ++, JNI использует специальное соглашение об именах. В нашем случае пакет называется com.dmitrii.doomsmartwatch, а класс — Doom. Чтобы создать метод init на C, нужно объединить эти имена:
#include <jni.h> JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init( JNIEnv* pEnv, jobject pThis, jbyteArray wadData, jstring wadFilename ) { ... } JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_main( JNIEnv* pEnv, jobject pThis) { ... } JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame( JNIEnv* pEnv, jobject pThis, jbyteArray screenBuffer) { ... }
После этого все вызовы Kotlin будут автоматически связаны с соответствующими C‑методами.
Чтобы использовать C или C++ в проекте Android Studio, нам также нужно добавить CMakeLists.txt и внести изменения в build.gradle.kts:
CMakeLists.txt: cmake_minimum_required(VERSION 3.22.1) project("doomsmartwatch") add_library(${CMAKE_PROJECT_NAME} SHARED # Список исходников C/C++ doomgeneric.c d_main.c ... ) target_link_libraries(${CMAKE_PROJECT_NAME} # Список библиотек, связанных с целевой библиотекой android log) build.gradle.kts: android { ... externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } }
Android Studio автоматически создает эти файлы, когда мы впервые добавляем класс C++ в проект. Связывать библиотеку log здесь необязательно, но это позволяет использовать отладку с помощью Logcat:
#include <stdio.h> #ifdef __ANDROID__ #include <android/log.h> #define printf(...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__) #define fprintf(a, ...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__) #define vfprintf(a, ...) __android_log_vprint(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__) #endif
2. Исходники DOOM
На этом этапе мы завершили «скучную» часть с конфигурациями CMake и переходим к самой интересной — изменениям в исходном коде DOOM.
2.1 WAD (Where's All Data)
Все игровые данные, включая уровни, звуки и т. д., хранятся в так называемом WAD‑файле, который обычно имеет имя, подобное «doom2.wad». Этот файл представляет собой контейнер, в котором собраны все данные, что объясняет его расширение — «Where's All Data». Если мы выберем другой файл, то запустим другую игру.
В проекте Android мы можем разместить этот файл в папке src/main/assets. Однако исходный код, написанный на C, использует метод fopen для чтения файла, и я не смог найти способ получить полный путь к ресурсам в Kotlin. Вместо этого мы можем отправить данные WAD‑файла в виде массива байтов:
import android.content.Context external fun init(wadData: ByteArray, wadFilename: String) filename = "doom2.wad" val wadData = context.assets.open(filename).readBytes() init(wadData, filename)
Соответствующий код на C выглядит следующим образом:
// C-код: extern char wadFileName[255]; extern unsigned int wadDataLength; extern unsigned char *wadFileData; JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init( JNIEnv* pEnv, jobject pThis, jbyteArray wadData, jstring wadFilename ) { // Имя WAD-файла const char *nativeString = (*pEnv)->GetStringUTFChars(pEnv, wadFilename, 0); strcpy(wadFileName, nativeString); (*pEnv)->ReleaseStringUTFChars(pEnv, wadFilename, nativeString); // Данные WAD-файла wadDataLength = (*pEnv)->GetArrayLength(pEnv, wadData); wadFileData = (unsigned char*)malloc(wadDataLength); jbyte* content_array = (*pEnv)->GetByteArrayElements(pEnv, wadData, 0); memcpy(wadFileData, content_array, wadDataLength); (*pEnv)->ReleaseByteArrayElements(pEnv, wadData, content_array, JNI_OK); }
Я не уверен в том, насколько обязательно wadFileName, но оно использовалось несколько раз в исходном коде, поэтому я решил оставить его как есть.
Исходный код DOOM хорошо структурирован, и все модули разделены на отдельные файлы. Например, все операции, связанные с файлами, сосредоточены в файле w_file.c. Здесь нам необходимо внести изменения в методы W_OpenFile и W_Read:
char wadFileName[255] = {0}; unsigned char *wadFileData = NULL; unsigned int wadDataLength = 0; wad_file_t *W_OpenFile(const char *path) { stdc_wad_file_t *result; // Старый код // fstream = fopen(path, "rb"); // if (fstream == NULL) // return NULL; result = Z_Malloc(sizeof(stdc_wad_file_t), PU_STATIC, 0); result->wad.mapped = NULL; result->wad.length = wadDataLength; // M_FileLength(fstream); result->fstream = NULL; return &result->wad; } size_t W_Read(wad_file_t *wad, long offset, void *buffer, size_t buffer_len) { // Старый код // fseek(stdc_wad->fstream, offset, SEEK_SET); // Read into the buffer. // size_t result = fread(buffer, 1, buffer_len, stdc_wad->fstream); size_t result = 0; if (wadFileData != NULL) { memcpy(buffer, &wadFileData[offset], buffer_len); result = buffer_len; } return result; }
Очевидно, что у нас уже есть все необходимые данные в буфере, поэтому нам больше не нужно использовать методы fseek и fopen. Размер WAD‑файла составляет примерно 16 МБ. Оригинальный DOOM использовал для чтения данных файл, проецируемый в память, но смарт‑часы в 2025 году в среднем имеют в 32 раза больше оперативной памяти (2 ГБ в сравнении с 64 МБ), чем персональные компьютеры 1996 года.
2.2 DoomMain
Второй метод, который мы должны модифицировать, находится в файле d_main.c и называется D_DOOMMAIN. Его исходный код выглядит следующим образом:
void D_DoomMain(void) { printf("Z_Init: Init zone memory allocation daemon. \n"); Z_Init(); ... M_LoadDefaults(); iwadfile = D_FindIWAD(IWAD_MASK_DOOM, &gamemission); W_CheckCorrectIWAD(doom); printf("I_Init: Setting up machine state.\n"); I_InitSound(True); printf("R_Init: Init DOOM refresh daemon - "); R_Init(); printf("\nP_Init: Init Playloop state.\n"); P_Init(); D_DoomLoop(); // бесконечный игровой цикл } void D_DoomLoop(void) { I_SetWindowTitle(gamedescription); I_SetGrabMouseCallback(D_GrabMouseCallback); I_InitGraphics(); V_RestoreBuffer(); R_ExecuteSetViewSize(); D_StartGameLoop(); while (1) { // фрейм-синхронизированные операции ввода-вывода I_StartFrame(); TryRunTics(); // выполнит хотя бы один тик S_UpdateSounds(players[consoleplayer].mo); // перемещает позиционные звуки // обновляет отображение следующего кадра текущим состоянием if (screenvisible) D_Display(); } }
Как и в «классических» приложениях, DOOM имеет бесконечный основной цикл, который обрабатывает все события и обновляет графику. Однако в Jetpack Compose этот подход не работает, и мы не можем блокировать основной цикл приложения таким образом. Тем не менее, исправить это не так сложно. Давайте реорганизуем метод D_DoomLoop, разделив его на две функции: D_DoomInitLoop и d_dooomloopstep. Вот как это будет выглядеть:
void D_DoomInitLoop(void) { I_SetWindowTitle(gamedescription); I_SetGrabMouseCallback(D_GrabMouseCallback); I_InitGraphics(); V_RestoreBuffer(); R_ExecuteSetViewSize(); D_StartGameLoop(); } void D_DoomLoopStep(void) { // фрейм-синхронизированные операции ввода-вывода I_StartFrame(); TryRunTics(); // выполнит хотя бы один тик S_UpdateSounds(players[consoleplayer].mo); // перемещает позиционные звуки // обновляет отображение следующего кадра текущим состоянием if (screenvisible) D_Display(); }
Теперь мы можем легко вызывать метод D_DoomLoopStep для обновления состояния игры каждый раз, когда Jetpack Compose обновляет представление.
2.3 Графика
Как видно из кода, игровой цикл всегда вызывает метод D_Display. Этот метод отвечает за обновление пользовательского интерфейса игры и вызывает метод I_FinishUpdate, расположенный в файле i_video.c. Однако сейчас нас интересует переменная DG_ScreenBuffer, которая используется в том же файле:
uint32_t DG_ScreenBuffer[DOOMGENERIC_RESX * DOOMGENERIC_RESY]; void I_FinishUpdate(void) { /* ЭКРАН ОТРИСОВКИ */ line_in = (unsigned char *) I_VideoBuffer; line_out = (unsigned char *) DG_ScreenBuffer; int y = SCREENHEIGHT; while (y--) { for (int i = 0; i < fb_scaling; i++) { line_out += x_offset; cmap_to_fb((void*)line_out, (void*)line_in, SCREENWIDTH); line_out += (SCREENWIDTH * fb_scaling * (s_Fb.bits_per_pixel/8)) + x_offset_end; } line_in += SCREENWIDTH; } DG_DrawFrame(); }
Ключевым моментом для нас здесь является то, что DOOM визуализирует всю свою графику в массиве DG_ScreenBuffer. Это идеально подходит для нашей задачи — после каждого игрового шага мы можем отправлять эти данные обратно в Kotlin.
// Сторона Kotlin: private external fun getFrame(screenBuffer: ByteArray) // Сторона C: JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame( JNIEnv* pEnv, jobject pThis, jbyteArray screenBuffer) { D_DoomLoopStep(); uint32_t *buffer = DG_ScreenBuffer; size_t bufferSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4; jboolean isCopy; jbyte *arr = (*pEnv)->GetByteArrayElements(pEnv, screenBuffer, &isCopy); memcpy(arr, buffer, bufferSize); (*pEnv)->ReleaseByteArrayElements(pEnv, screenBuffer, arr, JNI_OK); }
Очевидно, что оригинальный метод DG_DrawFrame больше не нужен, поэтому мы можем оставить его пустым:
void DG_DrawFrame(void) { }
3.1 Android-приложение: Jetpack Compose
Наконец, давайте создадим Android‑приложение, которое будет использовать наш код DOOM. Как уже упоминалось ранее, я создал приложение для Android Wear, но обычное приложение для Android тоже должно нормально работать.
Для отрисовки игрового экрана я буду использовать OpenGL. Прежде всего, нам нужно «обернуть» его в AndroidView:
@Composable fun WearApp() { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), contentAlignment = Alignment.Center ) { DoomGLView() } } @Composable fun DoomGLView() { val doom = remember { Doom() } val viewActive = remember { mutableStateOf(false) } LifecycleResumeEffect(Unit) { viewActive.value = true onPauseOrDispose { viewActive.value = false } } if (viewActive.value) { AndroidView( modifier = Modifier .fillMaxSize() .clipToBounds(), factory = { context -> DoomGLSurfaceView(context).apply { } }, update = { view -> }, onRelease = { view -> view.glClear() } ) } } class DoomGLSurfaceView(context: Context) : GLSurfaceView(context) { private val renderer: GLGameRenderer init { setEGLContextClientVersion(2) renderer = GLGameRenderer() setRenderer(renderer) renderMode = RENDERMODE_CONTINUOUSLY } fun glClear() = renderer.glClear() }
В этом фрагменте кода я использую небольшой хак с viewActive, который позволяет удалять представление, когда приложение больше не активно. Это единственный способ, который я нашёл, чтобы правильно высвободить все данные OpenGL. Без этого приложение не восстанавливалось корректно после перехода в фоновый режим (если кто‑то знает способ получше, пожалуйста, поделитесь им в комментариях).
Минимально рабочий код рендеринга OpenGL выглядит следующим образом:
import android.opengl.GLES20 class GLGameRenderer : GLSurfaceView.Renderer { override fun onSurfaceCreated(glUnused: GL10, config: EGLConfig) { } override fun onSurfaceChanged(glUnused: GL10, width: Int, height: Int) { GLES20.glViewport(0, 0, width, height) glInit() } override fun onDrawFrame(glUnused: GL10) { GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT) GLES20.glClearColor(0f, 0.5f, 0f, 1f) } fun glInit() { } fun glClear() { } }
Здесь мы не инициализируем никаких текстур или шейдеров, а просто очищаем экран цветом RGB = (0, 0.5, 0). Методы onSurfaceCreated и onSurfaceChanged вызываются, когда приложение создает представление. Ранее я установил для renderMode значение RENDERMODE_CONTINUOUSLY, чтобы операционная система постоянно вызывала метод onDrawFrame (в моём эмуляторе интервал между обновлениями составляет около 17 мс). При каждом вызове onDrawFrame мы можем обновлять состояние игры и перерисовывать изображение.
Хоть это приложение и не претендует на звание «Дизайн года», мы уже можем убедиться, что OpenGL функционирует должным образом. Если все было сделано правильно, мы должны увидеть зеленую поверхность, как показано на рисунке ниже:

Теперь, на заключительном этапе, настало время отобразить реальные игровые данные.
3.2. Android-приложение: OpenGL
Программирование на OpenGL — это обширная тема, и в этой статье я представлю лишь основные концепции, необходимые для запуска игры. Те, кто желает узнать больше, могут почитать официальную документацию на developer.android.com. Также в конце статьи вы можете найти ссылку на исходный код.
На предыдущем шаге мы создали класс GLGameRenderer. При каждом вызове onDrawFrame мы будем обновлять состояние игры и получать обновленный графический массив.
Код на Kotlin выглядит следующим образом:
// Привязка JNI к C++, обсуждавшаяся ранее private external fun getFrame(screenBuffer: ByteArray) val frameSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4; // 4 байта на пиксель val frameBuffer = ByteArray(size = frameSize) getFrame(frameBuffer)
В OpenGL мы можем отображать изображения в виде текстур. Чтобы нарисовать текстуру, нам необходимо создать два шейдера и саму текстуру:
private val textures = intArrayOf(0) private var vertexShader = 0 private var fragmentShader = 0 private var program = 0 private fun glInit() { vertexShader = loadShader( GLES20.GL_VERTEX_SHADER, VERTEX_SHADER_CODE ) fragmentShader = loadShader( GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER_CODE ) program = GLES20.glCreateProgram().also { program -> GLES20.glAttachShader(program, vertexShader) GLES20.glAttachShader(program, fragmentShader) GLES20.glLinkProgram(program) } uniformMvpMatrix = GLES20.glGetUniformLocation(program, "uMvpMatrix") attributeVertexPosition = GLES20.glGetAttribLocation(program, "aPosition") attributeTexturePosition = GLES20.glGetAttribLocation(program, "aCoordinate") uniformTexture = GLES20.glGetUniformLocation(program, "uTexture") GLES20.glGenTextures(1, textures, 0) }
OpenGL — это довольно старый фреймворк, созданный в 1990-х годах. В нем отсутствуют современные функции, такие как смарт‑объекты и сборщик мусора. Когда компонент высвобождается, нам необходимо вручную очистить выделенные под него ресурсы:
fun glClear() { if (program != 0) { GLES20.glDeleteProgram(program) program = 0 } if (vertexShader != 0) { GLES20.glDeleteShader(vertexShader) vertexShader = 0 } if (fragmentShader != 0) { GLES20.glDeleteShader(fragmentShader) fragmentShader = 0 } if (textures[0] != 0) { GLES20.glDeleteTextures(1, textures, 0) textures[0] = 0 } }
При каждом вызове метода onDrawFrame мы можем обновлять текстуру данными, полученными из DOOM:
const val GAME_WIDTH = 640 const val GAME_HEIGHT = 400 private fun updateTextureFromBuffer(byteArray: ByteArray) { val buffer = ByteBuffer.wrap(byteArray) GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]) GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE.toFloat()) GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE.toFloat()) GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat()) GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()) GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, GAME_WIDTH, GAME_HEIGHT, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer) GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0) }
Если все сделано правильно, мы можем запустить приложение и увидеть игру:

Что ж, это работает. Почти. Очевидно (по крайней мере, для тех, кто хотя бы раз играл в Doom:), что цветовое отображение некорректно.
Исправить это не так сложно. В коде мы можем увидеть константу GLES20.GL_RGBA. Однако в методе I_InitGraphics в коде DOOM цветовые каналы настроены иначе:
void I_InitGraphics(void) { memset(&s_Fb, 0, sizeof(struct FB_ScreenInfo)); s_Fb.xres = DOOMGENERIC_RESX; s_Fb.yres = DOOMGENERIC_RESY; s_Fb.bits_per_pixel = 32; s_Fb.blue.length = 8; s_Fb.green.length = 8; s_Fb.red.length = 8; s_Fb.transp.length = 8; s_Fb.blue.offset = 0; s_Fb.green.offset = 8; s_Fb.red.offset = 16; s_Fb.transp.offset = 24; ... }
Чтобы исправить эту проблему, нам нужно лишь подправить цветовые смещения, чтобы сделать их совместимыми с RGBA:
s_Fb.red.offset = 0; s_Fb.green.offset = 8; s_Fb.blue.offset = 16; s_Fb.transp.offset = 24;
Если все сделано правильно, мы должны увидеть на экране часов полностью работающий DOOM:

При записи видео некоторая резкость изображения теряется. На настоящих смарт‑часах игра выглядит очень четко, поскольку плотность пикселей на устройствах Android значительно выше, чем на обычных 14-дюймовых дисплеях с разрешением 800×600, которые использовались в 1990-х годах.
Заключение
В этой статье я рассказал, как портировать игру DOOM, созданную в 1993–1996 годах, на современный фреймворк Jetpack Compose для Android. Я протестировал игру на смарт‑часах просто забавы ради, но тот же подход должен работать и на «полноразмерном» устройстве Android.
Как мы видим, игра работает, но, к сожалению, в нее пока нльзя играть. Некоторые важные функции еще не реализованы:
Экранные элементы управления. Это может быть непросто на экране часов из‑за его небольшого размера, но это должно быть выполнимо.
Звук. Звуковой модуль еще не реализован.
Сеть / мультиплеер. Может быть забавно играть в Doom на двух Android‑устройствах, однако я понятия не имею, пробовал ли кто‑нибудь это реализовать.
Если вы хотите увидеть продолжение этой статьи, пожалуйста, поставьте лайк или оставьте комментарий ниже. Я буду ориентироваться на количество лайков и просмотров, чтобы понять, стоит ли писать следующую часть.
Если вы хотите глубже разобраться в инструментах и технологиях, используемых в проекте, вот несколько открытых уроков от Otus, которые вас точно заинтересуют:
3 апреля: Оптимизация CI/CD для мобильных тестов на Kotlin: как избавиться от нестабильных тестов и ускорить развертывание?
Записаться16 апреля: Контрактное тестирование в Kotlin QA: как гарантировать, что фронтенд и бэкенд понимают друг друга?
Записаться17 апреля: Применение возможностей Kotlin в UI тестировании
Записаться
Больше открытых уроков по мобильной разработке и не только ищете в календаре мероприятий.
