...которая работает на первых Android-смартфонах в мире, компьютерах из 90-х и даже Mac'ах! Часть 2.

Иногда у меня лежит душа просто взять и написать какую-нибудь небольшую игрушку с нуля, без использования готовых движков. В процессе разработки я ставлю перед собой интересные задачки: игра должна весить как можно меньше, работать на как можно большем числе платформ и использовать нетипичный для меня архитектурный паттерн. Недавно я начал писать ремейк классических «танчиков» и в рамках серии статей готов рассказать о всех деталях разработки трёхмерной игры с нуля в 2025 году. Если вам интересно узнать, как работают небольшие 3D-демки «под капотом» от написания фреймворка до разработки геймплея — жду вас под катом!

❯ Содержание:

  1. Предисловие

  2. Рендер

  3. Аллокации

  4. Ввод

  5. Тесты

  6. Заключение

❯ Предисловие

Ещё в начале этого года, мне взбрело в голову проверить насколько концепция «Write once, run anywhere» правдива. Все мы знаем, что Java достаточно обширно используется в Enterprise-секторе по типу банков, Android-гаджетах в качестве языка, на котором написано около 80% системы и даже в смарт-карточках, куда входят привычные нам SIM и банковские карты.

Изначально я хотел написать игру, которая работала бы не только на самых первых Android-смартфонах в мире, но ещё и на ретро-кнопочных телефонах, и при всём этом была 3D. В течении недели, я успел написать некоторые наработки для трёхмерной гоночки с примитивной физикой на основе «линий»:

В игре был мультиреднер для M3G и MascotCapsule... не хуже игр Fishlabs :))
В игре был мультиреднер для M3G и MascotCapsule... не хуже игр Fishlabs :))

Но затем я понял, что лишаюсь очень многих фич языка. Дело в том, что игры для Java-телефонов писались не столько на самой «джаве», сколько на её своеобразном диалекте. В мире C/C++ такой подход принято называть «C с классами», но в случае Java - подход заключался в написании большей части логики в одном-двух классах для улучшения производительности игры. Наследование, полиморфизм и абстракции на кнопочных телефонах использовать не рекомендуется. Кроме того, версия JDK в кнопочных телефонах была на уровне 1.3 — а значит, никаких дженериков и иных полезных фишек Java.

Про разработку игр для кнопочных телефонов я писал отдельную статью. Дабы не отвлекать вас от прочтения этой, ссылку оставлю в закрепленном комментарии :)
Про разработку игр для кнопочных телефонов я писал отдельную статью. Дабы не отвлекать вас от прочтения этой, ссылку оставлю в закрепленном комментарии :)

По итогу я решил сфокусироваться на относительно свежем HTC Dream — первом серийном Android-смартфоне в мире, который вышел в далёком 2008 году с Android 1.0 на борту. В нём используется уже не JVM, а своя виртуальная машина Dalvik с собственным байткодом и версией JDK — 1.5, да и процессор здесь значительно помощнее, а следовательно и куда больше возможностей для разработки!

Поскольку игру я разрабатываю и отлаживаю на ПК, у меня также есть отдельный билд и для ретро-компьютеров с GPU из 90-х и нулевых. И в рамках статьи, мы, конечно же, сделаем с вами практические тесты!

❯ Рендер

В первой части мы с вами закончили на том, что написали основу для игры — фреймворк, который включает в себя рендерер, менеджер ресурсов на слабых ссылках, некое подобие графа сцены с компонентной системой и загрузчик уровней. Но этого всё ещё мало для 3D-игры и, что самое важное, все эти модули ещё не оптимизированы.

Например, если грузить уровень «в лоб» и на каждый кубик выделять по отдельному игровому объекту, который «рисует сам себя отдельно» — мы быстро столкнемся с тем, что количество вызовов отрисовки (DIP'ов) превысит все разумные нормы. Для уровня в 16x16 блоков это уже целых 256 DIP'ов - а вкупе с другими танчиками и UI - не менее 260-270.

Самая базовая оптимизация в таком случае — это отсечение по пирамиде видимости (Frustum culling). Концепция простая: для отрисовки всего, что мы видим с вами на экране используется три матрицы размерности 4x4: мировая (позиция и поворот объекта в мире), вида (камера, позиция из «глаз») и проекции. При перемножении, они образуют так называемую WorldViewProjection-матрицу и если каждую вершину модели умножить на эту матрицу — то мы получаем её позицию в Clip-Space (или NDC) пространстве. Далее растеризатор берёт каждые три трансформированные вершины в качестве углов треугольника и отрисовывает их в рендертаргет - в нашем случае, это экран. Именно за счёт перспективной матрицы проекции и Z-буфера, мы с вами и получаем тот самый эффект трёхмерного пространства.

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

    public void calculate(Matrix viewProj) {
        float[] items = viewProj.Matrix;
        planes[0].set(items[3] - items[0], items[7] - items[4], items[11] - items[8], items[15] - items[12]).normalize();
        planes[1].set(items[3] + items[0], items[7] + items[4], items[11] + items[8], items[15] + items[12]).normalize();
        planes[2].set(items[3] + items[1], items[7] + items[5], items[11] + items[9], items[15] + items[13]).normalize();
        planes[3].set(items[3] - items[1], items[7] - items[5], items[11] - items[9], items[15] - items[13]).normalize();
        planes[4].set(items[3] - items[2], items[7] - items[6], items[11] - items[10], items[15] - items[14]).normalize();
        planes[5].set(items[3] + items[2], items[7] + items[6], items[11] + items[10], items[15] + items[14]).normalize();
    }

    // Allocation-less
    public boolean isPointInFrustum(float x, float y, float z)
    {
        for(int i = 0; i < planes.length; i++)
        {
            Plane plane = planes[i];

            if ((plane.A * x) + (plane.B * y) + (plane.C * z) + plane.D <= 0)
                return false;
        }

        return true;
    }

Далее проверить попадает ли наш кубик или танчик в кадр — дело техники. Есть два подхода: подсчитать Bounding-sphere для модели (радиус относительно самой нижней и самой верхней вершины), или Bounding-box. В самом простом случае, можно обойтись проверкой самой нижней и самой верхней точки Bounding-box'а, однако в некоторых случаях такой алгоритм может давать сбой — например если уткнутся в «стенку» носом в игре:

  public boolean isMeshRendererInFrustum(MeshRenderer renderer) {
        float x = renderer.Parent.Position.X;
        float y = renderer.Parent.Position.Y;
        float z = renderer.Parent.Position.Z;
        Vector min = renderer.Mesh.BoundingMin;
        Vector max = renderer.Mesh.BoundingMax;

        return isPointInFrustum(x + min.X, -(y + min.Y), z + min.Z) || isPointInFrustum(x + max.X, -(y + max.Y), z + max.Z);
    }

Конкретно в нашем случае, такая оптимизация помогает сэкономить около 100 DIP'ов и даёт неплохой прирост FPS. На Galaxy S3 с Mali 400MP4 мы получаем стабильные 60FPS, в то время как на Xperia Play — около 30... Что-ж, этого всё равно мало, тем более для смартфона, в котором GPU — кровный брат Xenos в Xbox 360...

Нарисовать 256 кубиков для GPU, даже мобильного — не проблема, особенно если они не бьют по филлрейту. Однако на классических мобильных GPU был строгий бюджет на число DIP'ов — в идеале не более 100, иначе FPS заметно просаживается даже на примитивной геометрии. Поэтому для оптимизации можно использовать технику батчинга: объединяем все кубики с одним материалом в сцене в одну большую модель и рисуем за один вызов DIPUP:

public void bake() {
        int uniqueMaterials = 0;

        batchList.clear();
        batchRenderers.clear();
        world.findComponentsOfType(BatchedMeshRenderer.class, batchRenderers);

        for(int i = 0; i < batchRenderers.size(); i++) {
            BatchedMeshRenderer renderer = batchRenderers.get(i);
            renderer.IsTakenByBatcher = false;

            if(renderer.Mesh != null && renderer.Material != null) {
                if(renderer.Mesh.Buffers.length != 1)
                    continue; // Only simple meshes is supported now

                Batch batch = meshes.get(renderer.Material);

                if(batch == null)
                    meshes.put(renderer.Material, batch = new Batch(renderer.Material));

                batch.addMesh(renderer);
            }
        }

        for(Map.Entry<Material, Batch> materialBatch : meshes.entrySet()) {
            batchList.add(new BatchHolder(materialBatch.getKey(), materialBatch.getValue()));
            materialBatch.getValue().finish(); // Upload mesh to GPU
        }
    }

После этого, FPS поднимается до очень приятных значений - целых 45! Однако есть и обратная сторона: эта техника очень сильно бьёт не только по памяти, но и в случае динамического батчинга (танки ведь уничтожают кубики) - по процессору. Однако можно и далее оптимизировать этот алгоритм путём разбиения батчей на сетку, чтобы отсекать невидимые группы "кубиков" :)

Следующая тема — это материалы для поверхностей, описывающие внешний вид модели на экране. В первой статье я написал базовую систему материалов, которая оборачивала в себе набор рендерстейтов и парочку текстур: Diffuse и Detail. Но мало кто помнит, что ещё до шейдеров, в FFP был довольно мощный инструмент, именуемый комбайнерами. По сути, комбайнеры — это возможность задействования сразу нескольких текстурных юнитов для смешивания двух и более текстур за один вызов отрисовки.

Пример использования комбайнеров — плавное смешивание двух текстур на ландшафте с использованием маски. Эдакая вариация техники Splat mapping
Пример использования комбайнеров плавное смешивание двух текстур на ландшафте с использованием маски. Эдакая вариация техники Splat mapping

Поэтому я решил написать загрузчик для материалов, описанных в простом текстовом формате по типу ini-файлов. В секции Texture описываются используемые текстуры, которые затем подгружаются из пула ресурсов, в RenderStates — напрямую указаны поля в классе Material, а в Combiners — очень-очень примитивная вариация на тему шейдеров!

[Texture]
Primary = textures/t72_diffuse.tex
Secondary = textures/brick.tex

[RenderStates]
AlphaTest = 0
AlphaTestValue = 1

DepthWrite = 1
DepthTest = 1
AlphaBlend = 0
Fog = 1
Unlit = 1

[Combiners]
Sample Primary
Interpolate Secondary 0.3
MultiplyColor Primary

Изначально я хотел сделать чтобы материалы описывали эдакий набор инструкций как «шейдеры» в Quake 3. Однако учитывая отсутствие лямбд в Java 1.5, реализация на интерфейсах (и тем более на рефлексии) не впечатлила своей производительностью и я решил сделать «программируемыми» только сами комбайнеры. Суть простая: отдельные псевдо-шейдеры реализуют интерфейс FixedFunctionShader и в теле метода onApply применяют необходимые операции над комбайнерами. При этом строго запрещается менять стейт самого графического API кроме биндинга текстур:

    static class Sample implements BaseGraphics.FixedFunctionShader {

        @Override
        public void onApply(Material material, int combiner, float[] params) {
            if(params.length != 1)
                throw new ShaderException(this, material, params, "Expected 1 argument");

            int texId = (int)params[0];
            Texture2D tex = material.Textures[texId];

            if(tex == null)
                throw new ShaderException(this, material, params, "Texture " + texId + " was null");

            tex.bind();

            glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB, GL_REPLACE);
            glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_ALPHA, GL_REPLACE);
            glTexEnvi(GL_TEXTURE_ENV, GL_SRC0_RGB, GL_TEXTURE0 + combiner);
            glTexEnvi(GL_TEXTURE_ENV, GL_SRC0_ALPHA, GL_TEXTURE0 + combiner);

            glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_RGB, GL_SRC_COLOR);
            glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_ALPHA, GL_SRC_ALPHA);
        }
    }

Затем при вызове отрисовки модели, рендерер выполняет «инструкции» для таких комбайнеров по одному и если нужно — откатывается до простой «однотекстурной» версии (драйвер GLES на Mali-400 и VideoCore IV не поддерживает комбайнеры, несмотря на то, что спецификация требует их поддержки). Получается довольно шустро:

      if(GPUClass.QualityLevel >= com.monobogdan.engine.GPUClass.QUALITY_LEVEL_NORMAL) {
            for (int i = 0; i < Material.COMBINER_STAGE_COUNT; i++) {
                // Reset combiner state
                glActiveTexture(GL_TEXTURE0 + i);
                glDisable(GL_TEXTURE_2D);
            }

            for (int i = 0; i < material.Shaders.length; i++) {
                Material.ShaderInstance instance = material.Shaders[i];

                glActiveTexture(GL_TEXTURE0 + i);
                glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE);
                glEnable(GL_TEXTURE_2D);
                instance.Shader.onApply(material, i, instance.Params);
            }
        } else {
            // Single texture fallback for very slow GPU's
            glActiveTexture(GL_TEXTURE0);
            setState(GL_TEXTURE_2D, material.Textures[0] != null);
            material.Textures[0].bind();
        }
Наполовину кирпичный танк — видели ли вы когда-нибудь такой камуфляж? :)
Наполовину кирпичный танк видели ли вы когда-нибудь такой камуфляж? :)

Следующая тема — рендеринг текста. В более ранних статьях я обычно не парился над демками и просто рисовал текст нативными средствами системы в текстуру, а затем рисовал полноэкранный квад. Такая методика работает шустро на смартфонах, но очень тормозная на ПК и более того, такая текстура занимает слишком много VRAM! Однако чаще всего я использую так называемые битмапные шрифты, которые состоят из атласа — текстуры с «запеченными» буквами и информации о том, где какой символ в ней находится. Для генерации таких шрифтов я использую утилиту BMFont, а сам код рендеринга получается очень простым:

    public void drawString(BitmapFont font, Vector color, float x, float y, String str) {
        if(font == null)
            throw new NullPointerException("font was null");

        if(str == null)
            return;

        int sz = font.Size / 2;

        for(int i = 0; i < str.length(); i++) {
            char chr = str.charAt(i);

            if(chr == ' ')
                x += sz;
            else {
                BitmapFont.CharacterInfo chrInfo = font.getCharacter(chr);
                drawImage(font.Pages[chrInfo.Page], x, y + chrInfo.YOffset, chrInfo.X, chrInfo.Y, chrInfo.Width, chrInfo.Height, chrInfo.Width, chrInfo.Height, color);
                x += chrInfo.Width;
            }
        }
    }

И результат - весьма симпатичным:

В целом, далее особо оптимизировать и нечего для рендерера. Инстансинга в FFP нет, шейдеров — тоже, а рендер идентичный и на Android, и на ПК. Поэтому имеем что имеем!

❯ Аллокации

Однако когда я начал отлаживать игру на смартфонах, я заметил резкие просадки кадров и абсолютно нестабильный FPS. При этом характер лагов был константный: раз в 2-3 секунды просадка в 20 кадров. Заглянув в logcat, я обнаружил что Dalvik постоянно вызывает GC (сборщик мусора) и блокирует все потоки на невероятные 16мс — даже для простейших объектов в «куче»! В зависимости от устройства, Dalvik выделяет от 8 до 32Мб памяти для каждого приложения - что очень немного!

В первой статье я рассказывал о том, что большинство объектов у меня мутабельные и предполагают аллокацию не в update/draw, а в конструкторе компонента. Это касается векторов, матриц и иных примитивных классов для различных расчетов — ведь в отличии от .NET, в Java нет Value-типов, которые можно выделить на стеке, кроме примитивов. Например, если в C# написать такой код для сложения двух векторов:

struct Vector3 {
  public float X, Y, Z;

  public Vector3(float x, float y, float z)
  {
    X = x;
    Y = y;
    Z = z;
  }

  public static Vector3 operator +(Vector3 a, Vector3 b)
  {
    return new Vector3(a.X + b.X, a.Y + b.y, a.Z + b.z);
  }
}

...

Transform.Position += Velocity;

То из-за того, что Vector3 — простая структура без ссылок на управляемые объекты, которая не требует контроля от GC, рантайм .NET выделит её на стеке, а не в куче и автоматически удалит при выходе из скоупа метода, где она использовалась. Если попытаться сделать такое в Java:

public static Vector3 add(Vector3 a, Vector3 b)
{
  return new Vector3(a.X + b.X, a.Y + b.y, a.Z + b.z);
}

...

transform.position = Vector3.add(transform.position, velocity);

То мы получим аллокацию для каждого объекта, вызывающий этот участок кода на каждый кадр. И когда придёт время вызывать GC — он обязательно тормознет игру и вызовет огромные фризы, прямо как в Minecraft на ПК. Главный нюанс здесь в том, что Dalvik оптимизирован под минимальное потребление памяти и поэтому начинает слишком часто вызывать GC, тормозя работу игры. В смартфонах с большим объёмом ОЗУ (хотя-бы 1Гб) таких проблем уже нет.

Но как я уже и сказал выше — мои игровые объекты и компоненты написаны так, чтобы не нагружать ни GC, ни кучу, но сборщик мусора всё равно продолжает тормозить игру, а значит нужно максимально экономить аллокации. Начав профайлить код, я обнаружил что огромное число аллокаций приходится на... итераторы! Да-да, та же самая проблема, что и в примере с векторами: даже несмотря на крошечный вес в памяти, итерации в каждом кадре засоряют хип и по итогу вызывают GC. Решение: перевести все индексированные списки на классический for:

        for(int i = 0; i < GameObjects.size(); i++) {
            GameObjects.get(i).onUpdate();
        }

        // Second pass for late updates
        for(int i = 0; i < GameObjects.size(); i++)
            GameObjects.get(i).onLateUpdate();

И после этого, частота вызова GC наконец-то стабилизировалась!

❯ Ввод

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

Затем необходимо замаппить физические кнопки на наш виртуальный геймпад. Для этого, на смартфонах я сделал таблицу с маппингом, которая подходит для большинства игровых гаджетов: Xperia Play, игровых консолей на Android'е из 2012-го и даже смартфонов с аппаратными QWERTY-клавиатурами. И если захочется добавить возможность переназначения кнопок — это тоже не станет проблемой!

    private static int[] xperiaPlayMapping = {
            KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER,
            KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_BUTTON_L1
    };

    private static int[] genericQWERTYMapping = {
            KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_W, KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_K,
            KeyEvent.KEYCODE_Q, KeyEvent.KEYCODE_E
    };

    public static int[][] ConversionTable = {
        xperiaPlayMapping,
        genericQWERTYMapping
    };

...

  private int resolveGamePadTranslationTable(int keyCode) {
        for(int i = 0; i < GamePadKeyTable.ConversionTable.length; i++) {
            int[] keys = GamePadKeyTable.ConversionTable[i];

            for(int j = 0; j < keys.length; j++) {
                if(keyCode == keys[j])
                    return j;
            }
        }

        return -1; // Not resolved
    }

    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        int gamePadKey = resolveGamePadTranslationTable(keyCode);
        handleKeyEvent(event.getScanCode(), Input.STATE_RELEASED);

        if(gamePadKey != -1)
            handleGamePadEvent(gamePadKey, Input.STATE_RELEASED);

        return true;
    }

По итогу, у нас есть унифицированное управление на ПК и смартфонах, покататься в нашей демке можно даже на легендарной Xperia Play!

Для смартфонов без аппаратной клавиатуры, виртуальный геймпад пишется буквально за 5 минут. Главное — использовать относительные нормализованные координаты для адаптивности и учитывать Aspect Ratio устройства, который может быть разным:

    public void drawUI() {
        VerticalInput = 0;
        HorizontalInput = 0;

        float scaled = UI_BASE_SIZE * Scale;
        float baseY = 1.0f - (scaled * 3); // 0.7f is base coefficient for 1.0f scaling

        if(game.Runtime.UI.imageButton(arrowUp, scaled, baseY, scaled, scaled, true))
            VerticalInput = 1;

        if(game.Runtime.UI.imageButton(arrowDown, scaled, baseY + (scaled * 2), scaled, scaled, true))
            VerticalInput = -1;

        if(game.Runtime.UI.imageButton(arrowLeft, 0.0f, baseY + scaled, scaled, scaled, true))
            HorizontalInput = -1;

        if(game.Runtime.UI.imageButton(arrowRight, scaled * 2, baseY + scaled, scaled, scaled, true))
            HorizontalInput = 1;
    }

❯ Тестируем игру

Пришло время протестировать то, что мы успели с вами сделать за неделю. И сегодня в тестах участвует сразу несколько машинок: Asus eeePC 4G в роли «компьютера из 90-х», Sony Ericsson Xperia Play, iPhone 4S с нюансом и Samsung Galaxy Y Pro. Все гаджеты по своему хороши, имеют разные GPU и всех их объединяет статус легендарных.

Начинаем с SE Xperia Play 2011 года выпуска, который изначально позиционировался как игровой смартфон. По сути, Xperia Play - чуточку переделанный Xperia Pro, где QWERTY-клавиатуру заменили на геймпад, при этом аппаратная платформа почти всех "сонериков" 2011 года идентичная: чипсет Qualcomm MSM8250 с ARMv7-совместимым ядром Scorpio на частоте 1ГГц и GPU Adreno 205 (ребрендинг ATI Imageon Z430, на архитектуре Xenos), 512Мб ОЗУ типа DDR1 и 512Мб флэш-памяти. С смартфонами в те годы была такая же ситуация, как и с компьютерами в начале нулевых: прогресс был слишком быстрым и уже в 2012 году, Xperia Play не тянул многие свежие игры из-за слабенького процессора и GPU!
Но в нашем случае, он показывает себя неплохо и стабильно тянет рендеринг уровня и танчика в 40-45 FPS... В играх на Unity3D, Adreno 205 таким результатом похвастаться не мог.

Переходим к iPhone 4S, который, как я уже сказал, с некоторым нюансом: это китайская реплика на Android. При этом довольно интересен тот факт, что у копии очень крутая IPS-матрица почти такого же разрешения (800x480 против 960x640), как и на оригинальном айфоне. Работает "клон" на базе чипсета MediaTek MT6515 2012 года выпуска с одним ядром Cortex-A9, работающим на частоте 1ГГц и GPU PowerVR SGX531 Ultra. Также в смартфоне установлено 256Мб оперативной памяти и 256Мб постоянной - в общем, типичный бюджетник тех лет. GPU от PowerVR - главное достоинство этого смартфона в плане гейминга, наша демка спокойно выдаёт 50-60 стабильных FPS. Я считаю что это прекрасный результат.

Далее посмотрим на "экзотику" - Samsung Galaxy Y Pro. Этот смартфон интересен не только своей QWERTY-клавиатурой, но и очень диковинным (и родственным Raspberry Pi) процессором Broadcom BCM21553 с одним ARMv6-совместимым ядром на частоте 832МГц и крайне необычным GPU собственной разработки VideoCore IV. Дело в том, что GPU в чипсетах Broadcom выполняет роль системного монитора и по архитектуре заметно отличается от классических видеоускорителей. По сути, это DSP с очень крутым векторным сопроцессором из-за чего его отчасти можно назвать софтрендером. Однако ранние драйвера для этого GPU были очень сырыми из-за чего большинство игр выдавали артефакты или работали очень медленно. Наша игрушка - не исключение, всего лишь 20 FPS при 240x320...

Переходим к довольно необычной машинке: Asus eeePC 4G. Первые модели легендарной линейки нетбуков отличались очень низкой ценой, довольно слабым и прожорливым процессором Celeron M 353 на архитектуре Dothan (прямой потомок Pentium III Tualatin) и частоте 900МГц, встроенной графикой Intel GMA900 с поддержкой пиксельных шейдеров 2.0 и довольно небольшим объёмом ОЗУ в 512Мб типа DDR2. Здесь я проводил тесты на JRE 1.7 - и получил почти 60 FPS... за вычетом того, что раз в 3-4 секунды я получаю микрофризы и нагрузку на процессор в 80%. Однако сама JRE здесь не причём: такая высокая нагрузка связана с тем, что у GPU нет аппаратного вершинного конвейера и поэтому вся трансформация геометрии происходит на процессоре. Такой вот нюанс:

❯ Заключение

Вот такая статья о разработке 3D-игры с нуля у нас с вами получилась. Прошлые статьи в этой рубрике я писал в стиле туториала, но в этой я решил рассмотреть конкретные кейсы и архитектурные решения. И может она не настолько простая и понятная, как статья про разработку «самолетиков» или Top-Down стрелялки по зомби, думаю своего читателя она точно нашла! Если вам интересно, с кодом можно ознакомиться на моём Github.

А если вам интересна тематика ремонта, моддинга и программирования для гаджетов прошлых лет — подписывайтесь на мой Telegram-канал ‭«Клуб фанатов балдежа‭», куда я выкладываю бэкстейджи статей, ссылки на новые статьи и видео, а также иногда выкладываю полезные посты и щитпостю. А ролики (не всегда дублирующие статьи) можно найти на моём YouTube канале.

Очень важно! Разыскиваются девайсы для будущих статей!

Друзья! Для подготовки статей с разработкой самопальных игрушек под необычные устройства, объявляется розыск телефонов и консолей! В 2000-х годах, китайцы часто делали дешевые телефоны с игровым уклоном — обычно у них было подобие геймпада (джойстика) или хотя бы две кнопки с верхней части устройства, выполняющие функцию A/B, а также предустановлены эмуляторы NES/Sega. Фишка в том, что на таких телефонах можно выполнять нативный код и портировать на них новые эмуляторы, чем я и хочу заняться и написать об этом подробную статью и записать видео! Если у вас есть телефон подобного формата и вы готовы его задонатить или продать, пожалуйста напишите мне в Telegram (@monobogdan) или в комментарии. Также интересуют смартфоны-консоли на Android (на рынке РФ точно была Func Much-01), там будет контент чуточку другого формата :)

А также я ищу старые (2010-2014) подделки на брендовые смартфоны Samsung, Apple и т. п. Они зачастую работают на весьма интересных чипсетах и поддаются хорошему моддингу, парочку статей уже вышло, но у меня ещё есть идеи по их моддингу! Также может у кого-то остались самые первые смартфоны Xiaomi (серии Mi), Meizu (ещё на Exynos) или телефоны Motorola на Linux (например, EM30, RAZR V8, ROKR Z6, ROKR E2, ROKR E5, ZINE ZN5 и т. п., о них я хотел бы подготовить специальную статью и видео т. к. на самом деле они работали на очень мощных для своих лет процессорах, поддавались серьезному моддингу и были способны запустить даже Quake!). Всем большое спасибо за донаты!

А ещё я держу все свои мобилы в одной корзине при себе (в смысле, все проекты у одного облачного провайдера) — Timeweb. Потому нагло рекомендую то, чем пользуюсь сам — вэлкам:

Опробовать ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале 

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


  1. bodyawm Автор
    05.07.2025 14:13

    Ну что друзья, вот такая интересная статья у нас с вами получилась. Надеюсь, вам было интересно! Вот обещанные ссылки на другие мои статьи:

    Пишем 3D-игру весом в 600кб... - первая часть

    Как работали трёхмерные игры на кнопочных телефонах нулевых

    Сам написал, сам полетал: как и зачем я написал 3D-игру для компьютеров из 90-х


    1. bodyawm Автор
      05.07.2025 14:13

      Касательно судьбы этой игрушки, я пока подумываю. Скорее всего я доделаю играбельный клон BattleCity и релизну в маркет, при этом главная фича будет в том, что игра должна весить менее 600Кб в ПОЛНОМ виде - включая пак уровней, текстуры, модели и соответственно код. Никаких AppCompat'ов, androidx и прочих Google Play Services API - только голое API Android и GLES, без рекламы и донатов!


    1. bodyawm Автор
      05.07.2025 14:13

      На следующей неделе мы с вами постараемся установить Linux на один очень интересный детский ARM-ноутбук из 2010 года. Некий российский бренд выпустил "Бумбук" с интересным процессором от VIA, для которого есть исходный код ядра...

      Красноглазие обеспечено!

      Скрытый текст


  1. AntikranStudio
    05.07.2025 14:13

    Очень интересная статья, хотя я никогда не занимался разработкой такого рода с нуля.

    Многие этапы хорошо расписаны, логичное повествование, конечно без знания языка некоторые финты не понятны, но в общем конструкции функций логичны и доступны, даже без знания языка. (Я джун на крестах).

    Уместить 3D игру в 600кб, для меня это на уровне фантастики.

    Очень хорошо проведенное время за чтением. Спасибо.


    1. bodyawm Автор
      05.07.2025 14:13

      Благодарю за теплые слова) Стараюсь)

      Я просто не таскаю никаких зависимостей, мне с головой хватает того, что есть в стандартной библиотеке языка) В случае Java есть вообще всё - тредпулы разных видов, разные примитивы синхронизации, дженерики с разными видами коллекций и т.п.


  1. savostin
    05.07.2025 14:13

    <грамар наци>какие легкие раньше устройства были, всего 600кб</>

    А статья торт.


    1. bodyawm Автор
      05.07.2025 14:13

      Ахах, спасибо, да я как то не подумав заголовок поменял в последний момент