[Перевод] Kotlin и Jetpack Compose: портируем DOOM на смарт-часы

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
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
#ifdef __ANDROID__
#include
#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 тестировании
Записаться
Больше открытых уроков по мобильной разработке и не только ищете в календаре мероприятий.