…которая будет работать на первых Android-смартфонах в мире, ретро-компьютерах из 90-х и даже Mac’ах! Часть 1.
Иногда у меня лежит душа просто взять и написать какую-нибудь небольшую игрушку с нуля, без использования готовых движков. В процессе разработки я ставлю перед собой интересные задачки: игра должна весить как можно меньше, работать на как можно большем числе платформ и использовать нетипичный для меня архитектурный паттерн. Недавно я начал писать трёхмерные «танчики», которые должны весить не более 600 килобайт с учетом всех ресурсов и в рамках подробной статьи готов рассказать о всех деталях разработки трёхмерной игры с нуля в 2025 году. Если вам интересно узнать, как работают небольшие 3D-демки «под капотом» от написания фреймворка до разработки геймплея — жду вас под катом!
❯ Предисловие
Разработка небольших 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, а также 4×4 матрицы с самыми типичными представлениями).

Ради оптимальной производительности я сразу же решил для себя использовать «свой» кодстайл и определенные практики вместо общепринятых в 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 loop"); Runtime.init(); while(!Display.isCloseRequested()) { Display.processMessages(); Runtime.Graphics.setViewport(Display.getWidth(), Display.getHeight()); Runtime.update(); Runtime.draw(); try { Display.swapBuffers(); } catch (LWJGLException e) { log("SwapBuffers failed"); } } 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 game"); loadingResult = WorldLoader.Instance.load(Runtime, world, "test"); } public void update() { if(loadingResult.isDone()) { if(!loadingResult.isSuccessful()) throw new RuntimeException("Loading task cancelled due to exception"); else world.update(); } } public void draw() { if(loadingResult.isDone() && loadingResult.isSuccessful()) 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 thread"); if(worker == null) throw new NullPointerException("Worker can't be null for thread " + name); final AsyncResult res = new AsyncResult(runtime, name); worker.onBeforeLoad(res); res.future = execService.submit(new Runnable() { @Override public void run() { runtime.Platform.log("Started loading thread %s", res.getThreadName()); worker.onLoad(res); runtime.Platform.log("Loading thread %s successfully 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 references removal over time return null; } if(obj.getClass() != expectedClass) throw new ClassCastException("Object of name " + name + " is instance of " + obj.getClass().getSimpleName() + ", but getNamedObject expected " + expectedClass.getSimpleName()); return obj; } return null; } private void addObjectToPool(String name, Object obj) { loadedObjects.put(name, new WeakReference<Object>(obj)); } public Texture2D getTexture(String name) { Texture2D tex = (Texture2D) getNamedObject(name, Texture2D.class); if(tex == null) { tex = TextureLoader.load(runtime, name); addObjectToPool(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", glGetString(GL_RENDERER)); context.log("Checking extension support"); String extensions = glGetString(GL_EXTENSIONS); requireExtension(extensions, "GL_SGIS_generate_mipmap"); orthoMatrix = new Matrix(); // Initialize basic state glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_TEXTURE_COORD_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glEnable(GL_LIGHTING); glEnable(GL_CULL_FACE); glCullFace(GL_FRONT); matrixBuffer = ByteBuffer.allocateDirect(4 * 16); matrixBuffer.order(ByteOrder.nativeOrder()); matrixBuf = 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 indices"); buildIndices(subMesh.getValue(), verts, indices); output.writeInt(verts.size()); output.writeInt(indices.size()); for(Vertex vert : verts) { output.writeFloat(vert.X); output.writeFloat(vert.Y); output.writeFloat(vert.Z); output.writeFloat(vert.NX); output.writeFloat(vert.NY); output.writeFloat(vert.NZ); output.writeFloat(vert.U); output.writeFloat(vert.V); } for(Short i : indices) output.writeShort(i); verts.clear(); indices.clear(); }
А вот с текстурами пришлось подумать. Дело в том, что типичная 16-и битная текстура 256×256 занимает целый 131 килобайт памяти, что для наших целей слишком много. Умные дяди из S3 Graphics ещё в конце 90-х придумали формат S3TC (сегодня известный как DXT) во времена 4Мб видеокарт, который позволял сжать 16 пикселей в 8 байт, но мобильные GPU кроме Tegra его не поддерживают, а распаковывать его «на лету» не так то просто.

Поэтому обратившись к опыту предков, я решил использовать 4х-битные палитровые текстуры, которые могут иметь не более 16 цветов. На первый взгляд кажется, что 16 цветов будет слишком мало для большинства текстур и написав простенький алгоритм квантования, который сортирует самые частые цвета в изображении и формируют по ним палитру, я пришёл к такому же выводу:
Однако Photoshop умеет преобразовывать изображения в палитровые с минимальной потерей качества! Текстура тех же размеров, визуально ничем не отличающаяся от RGB565, будет весить всего 32 килобайта, а если далее её пожать Deflate’ом — 24 килобайта. Текстура 128×128 так вообще ПЯТЬ килобайт — это точно вин!
Единственный момент — ни один мобильный 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 (пока ещё очень сырая демка, там ещё есть что порефакторить и переписать).
А если вам интересна тематика ремонта, моддинга и программирования для гаджетов прошлых лет — подписывайтесь на мой 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-канале ↩
ссылка на оригинал статьи https://habr.com/ru/articles/918270/
Добавить комментарий