В прошлой статье о создании игры для консоли Anbernic я использовал библиотеку SDL2 и писал Google Dinosaur на C++. В этой статье я расскажу, как писать 3D игры на C# .NET8. Для примера напишу примитивный клон Майнкрафта.

Как и в прошлый раз, игра будет продублирована на 2 платформы, на Windows x86-64 и на Linux ARM64. Для удобства следовало бы подобрать кроссплатформенную библиотеку, которая позволила бы собирать один и тот же проект под обе платформы, однако все оказалось не так просто. Что же могло пойти не так!?, это ж .NET, там всё для людей. Я сделал ставку на OpenTK, замечательную, развивающуюся, актуальную, кроссплатформенную библиотеку с хорошей документацией и примерами. Там даже есть примеры для OpenGL Embedded System, но у меня почему-то не получилось правильно настроить контекст для рендеринга напрямую в буфер дисплея через API библиотеки. Я почти уверен, что это возможно, но я устал биться головой об стену и просто написал свою собственную низкоуровневую динамическую библиотеку, которая импортирует функции OpenGL в .NET. Моя библиотека настраивает контекст для рендеринга в буфер дисплея и занимается обработкой ввода с геймпада. Библиотека пытается неуклюже походить на OpenTK, но никакой совместимости там нет. Назвал я это чудо AnbernicTK.

Библиотека сыровата и содержит только базовые функции, но она является низкоуровневой прослойкой между железом консоли и кодом игры, которая работает из коробки. Я написал для примера код, который выводит треугольник на экран. При нажатии на кнопки LEFT и RIGHT треугольник вращается. Создаем проект С# .NET8, вставляем в Program.cs этот код:

TriangleExample
using AnbernicTK;
class Program
{
    static AnbernicWindow window = new AnbernicWindow(640, 480, "Rotating Triangle");
    static uint program;
    static uint vao;
    static uint vbo;
    static int uMvpLocation;
    static float rotation = 0.0f;

    static void Main()
    {
        window.Load += OnLoad;
        window.UpdateFrame += OnUpdateFrame;
        window.RenderFrame += OnRenderFrame;

        window.Run(60.0);
    }

    static void OnLoad(object? sender, EventArgs e)
    {
        string vertexShaderSource = @"#version 320 es
        precision mediump float;
        layout(location = 0) in vec3 aPosition;
        uniform mat4 uMvp;
        void main()
        {
            gl_Position = uMvp * vec4(aPosition, 1.0);
        }";

        string fragmentShaderSource = @"#version 320 es
        precision mediump float;
        out vec4 fragColor;
        void main()
        {
            fragColor = vec4(1.0, 0.5, 0.2, 1.0);
        }";

        InputManager.Initialize();

        uint vertexShader = GL.glCreateShader(GL.GL_VERTEX_SHADER);
        GL.ShaderSource(vertexShader, vertexShaderSource);
        GL.glCompileShader(vertexShader);

        uint fragmentShader = GL.glCreateShader(GL.GL_FRAGMENT_SHADER);
        GL.ShaderSource(fragmentShader, fragmentShaderSource);
        GL.glCompileShader(fragmentShader);

        program = GL.glCreateProgram();
        GL.glAttachShader(program, vertexShader);
        GL.glAttachShader(program, fragmentShader);
        GL.glLinkProgram(program);

        uMvpLocation = GL.glGetUniformLocation(program, "uMvp");

        float[] vertices = {
            -0.5f, -0.5f, 0.0f,
             0.5f, -0.5f, 0.0f,
             0.0f,  0.5f, 0.0f
        };

        GL.glGenVertexArrays(1, out vao);
        GL.glGenBuffers(1, out vbo);

        GL.glBindVertexArray(vao);
        GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo);
        GL.BufferData(GL.GL_ARRAY_BUFFER, vertices, GL.GL_STATIC_DRAW);

        GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, false, 3 * sizeof(float), IntPtr.Zero);
        GL.glEnableVertexAttribArray(0);
    }

    static void OnUpdateFrame(object? sender, EventArgs e)
    {
        if (InputManager.DPad.LEFT) rotation -= 0.02f;
        if (InputManager.DPad.RIGHT) rotation += 0.02f;

        if (InputManager.Button.MODE) window.Close();
    }

    static void OnRenderFrame(object? sender, EventArgs e)
    {
        if (window == null) return;
        GL.glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        GL.glClear(GL.GL_COLOR_BUFFER_BIT);

        GL.glUseProgram(program);

        float aspect = (float)window.Width / window.Height;
        float[] projection = CreateOrthographic(-aspect, aspect, -1, 1, -1, 1);
        float[] view = CreateIdentity();
        float[] model = CreateRotationZ(rotation);

        float[] mvp = MultiplyMatrices(projection, MultiplyMatrices(view, model));

        GL.glUniformMatrix4fv(uMvpLocation, 1, false, mvp);

        GL.glBindVertexArray(vao);
        GL.glDrawArrays(GL.GL_TRIANGLES, 0, 3);

        window.SwapBuffers();
    }

    static float[] CreateOrthographic(float left, float right, float bottom, float top, float near, float far)
    {
        return new float[] {
            2.0f / (right - left), 0.0f, 0.0f, 0.0f,
            0.0f, 2.0f / (top - bottom), 0.0f, 0.0f,
            0.0f, 0.0f, -2.0f / (far - near), 0.0f,
            -(right + left) / (right - left), -(top + bottom) / (top - bottom), -(far + near) / (far - near), 1.0f
        };
    }

    static float[] CreateIdentity()
    {
        return new float[] {
            1.0f, 0.0f, 0.0f, 0.0f,
            0.0f, 1.0f, 0.0f, 0.0f,
            0.0f, 0.0f, 1.0f, 0.0f,
            0.0f, 0.0f, 0.0f, 1.0f
        };
    }

    static float[] CreateRotationZ(float angle)
    {
        float cos = (float)Math.Cos(angle);
        float sin = (float)Math.Sin(angle);

        return new float[] {
            cos, -sin, 0.0f, 0.0f,
            sin,  cos, 0.0f, 0.0f,
            0.0f, 0.0f, 1.0f, 0.0f,
            0.0f, 0.0f, 0.0f, 1.0f
        };
    }

    static float[] MultiplyMatrices(float[] a, float[] b)
    {
        float[] result = new float[16];

        for (int i = 0; i < 4; i++)
        {
            for (int j = 0; j < 4; j++)
            {
                result[i * 4 + j] = 0.0f;
                for (int k = 0; k < 4; k++)
                {
                    result[i * 4 + j] += a[i * 4 + k] * b[k * 4 + j];
                }
            }
        }

        return result;
    }
}

Заметьте, что шейдеры для OpenGL ES имеют свою специфику. Просто скопировать шейдеры с ПК не получится, учитывайте это.

Для сборки под Anbernic надо правильно настроить конфигурацию проекта в .csproj файле:

Конфигурация проекта
<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<!-- Тип выходного файла: исполняемое приложение (EXE) -->
		<OutputType>Exe</OutputType>

		<!-- Целевая версия .NET: .NET 8.0 -->
		<TargetFramework>net8.0</TargetFramework>

		<!-- Автоматически добавляет using для часто используемых пространств имен (System, System.Collections.Generic и т.д.) -->
		<ImplicitUsings>enable</ImplicitUsings>

		<!-- Включает систему аннотаций nullable-типов для статического анализа кода -->
		<Nullable>enable</Nullable>

		<!-- Целевая платформа выполнения: Linux на архитектуре ARM64 -->
		<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>

		<!-- Создание единого исполняемого файла со всеми зависимостями внутри -->
		<PublishSingleFile>true</PublishSingleFile>
		
		<!-- Приложение будет содержать всю среду вы��олнения .NET (не требует установленного .NET на целевой системе) -->
		<SelfContained>true</SelfContained>

		<!-- Включение оптимизаций компилятора для повышения производительности -->
		<Optimize>true</Optimize>

		<!-- Отключает многоуровневую компиляцию JIT для более стабильной производительности -->
		<TieredCompilation>false</TieredCompilation>

		<!-- Удаление неиспользуемых секций IL-кода для уменьшения размера -->
		<EnableStripILSection>true</EnableStripILSection>

		<!-- Сжатие данных в едином файле для уменьшения размера -->
		<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>

		<!-- Включение обрезки неиспользуемого кода из зависимостей -->
		<PublishTrimmed>true</PublishTrimmed>

		<!-- Режим обрезки: 'link' удаляет неиспользуемые методы и типы на уровне IL -->
		<TrimMode>link</TrimMode>

		<!-- Удаление отладочных символов для уменьшения размера -->
		<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>

		<!-- Инструкции целевой архитектуры процессора: ARM64 -->
		<IlcInstructionSet>arm64</IlcInstructionSet>

		<!-- Приоритет оптимизации: скорость выполнения (вместо размера файла) -->
		<IlcOptimizationPreference>Speed</IlcOptimizationPreference>

		<!-- Отключение данных для трассировки стека для уменьшения размера -->
		<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>

		<!-- Отключение поддержки EventSource для диагностики (уменьшение размера) -->
		<EventSourceSupport>false</EventSourceSupport>

		<!-- Использование системных ключей ресурсов вместо локализованных строк -->
		<UseSystemResourceKeys>true</UseSystemResourceKeys>

		<!-- Отключение стандартного обработчика HttpClient (если не используется) -->
		<HttpClientHandler>false</HttpClientHandler>
		
	</PropertyGroup>

	<PropertyGroup Condition="'$(Configuration)'=='Release'">

		<!-- Полное отключение отладочной информации -->
		<DebugType>none</DebugType>

		<!-- Отключение файлов символов (PDB) -->
		<DebugSymbols>false</DebugSymbols>

		<!-- Компиляция Ahead-of-Time (AOT) в машинный код для ускорения запуска -->
		<PublishReadyToRun>true</PublishReadyToRun>

		<!-- Отключение символов для ReadyToRun компиляции -->
		<PublishReadyToRunEmitSymbols>false</PublishReadyToRunEmitSymbols>

		<!-- Оптимизация ReadyToRun компиляции для скорости выполнения -->
		<ReadyToRunOptimization>Speed</ReadyToRunOptimization>
		
	</PropertyGroup>

	<ItemGroup>
		<!-- Ссылка на библиотеку AnbernicTK -->
		<ProjectReference Include="..\AnbernicTK\AnbernicTK.csproj" />
	</ItemGroup>

	<ItemGroup>
		<!-- Отключение конкурентного GC (лучше для однопоточных игр) -->
		<RuntimeHostConfigurationOption Include="System.GC.Concurrent" Value="false" />

		<!-- Использование рабочей (workstation) версии GC вместо серверной -->
		<RuntimeHostConfigurationOption Include="System.GC.Server" Value="false" />

		<!-- Минимальное количество рабочих потоков в пуле -->
		<RuntimeHostConfigurationOption Include="System.Threading.ThreadPool.MinThreads" Value="1" />

		<!-- Максимальное количество рабочих потоков в пуле (ограничение для экономии ресурсов) -->
		<RuntimeHostConfigurationOption Include="System.Threading.ThreadPool.MaxThreads" Value="2" />
		
	</ItemGroup>

</Project>

Затем открываем терминал, переходим в директорию проекта и вводим команду на сборку:

dotnet publish -c Release -r linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o ./publish
Терминал
Терминал

Если все сделано правильно, то в директории проекта появится папка publish, в которой появится исполн��емый файл.

Теперь надо написать минимальный bash скрипт для запуска этого исполняемого файла. Создаем текстовый файл с расширением .sh и вставляем в него скрипт:

#!/bin/bash
cd "$(dirname "$0")/TriangleExample"
chmod +r /dev/input/event1
chmod +x "./TriangleExample" 2>/dev/null
exec "./TriangleExample"

Теперь можно сбрасывать эти файлы на консоль. Можно просто закинуть на SD карточку через переходник, но в процессе разработки вам понадобится часто редактировать файлы, поэтому советую установить WinSCP для доступа к файловой системе с рабочего ПК через SSH. Для этого подключитесь к одной точке доступа Wi-Fi с ПК и с консоли, включите SSH в настройках консоли, откройте WinSCP на ПК и подключитесь к консоли.

Подключение через SSH
Подключение через SSH

Имя пользователя и пароль root. В имя хоста введите IP адрес консоли, его можно посмотреть в настройках сети.

Открываем проводник, переходим в директорию /mnt/sdcard/Roms/APPS, создаем там папку с проектом TriangleExample и перемещаем в эту папку исполняемый файл из папки publish. В этой же папке могут храниться ресурсы проекта, но пока их нет.

Рядом с папкой проекта кладём bash скрипт. Всё, теперь можно брать в руки консоль и пробовать запустить файл. Через графический интерфейс запускаем программу. Если она вылетела, то убедитесь, что все файлы переместились корректно и содержат в себе данные. У меня иногда бывало так, что шейдер потерялся или файл есть, а данных в нем нет. Не знаю, с чем это связано, пробуйте и всё получится.

Самое время сказать, что это все будет работать только на Anbernic с заводской прошивкой. На R36S с ArkOS это почему-то не работает, даже не знаю почему. А вот на Anbernic RG40XXH, RG40XXV и RG35XXSP с заводской прошивкой  все  работает отлично.

Первый треугольник
Первый треугольник

С треугольником разобрались, теперь давайте сделаем что-нибудь поинтереснее. Замахнемся на воксельного гиганта. Сделаем базовый минимум - процедурную генерацию мира, редактирование мира и распространение света.

Для единообразия кода на ПК и на консоли пришлось написать средний слой абстракции, который забирает на себя низкоуровневую работу с OpenGL. В примерах библиотеки OpenTK есть папочка Common, в который лежат классы Camera.cs, Shader.cs и Texture.cs. Эти классы забирают на себя конфигурацию камеры и манипуляцию камерой, загрузку и сборку шейдерных программ, манипуляцию с шейдерной программой, загрузку текстур и прочее. Я написал аналоги этих классов для своей библиотеки. Весь дальнейший код может быть общим и может быть перенесен с ПК на Anbernic почти без изменений.

Посмотрим, с чем нам предстоит работать. Anbernic RG40XXH имеет четырехъядерный ARM процессор Cortex-A53 на 1.5 ГГЦ, графический процессор Mali G31 MP2, 1 ГБ оперативной памяти и экран с разрешением 640*480 пикселей. Что это значит для программиста? Это значит, что на Anbernic однопоточная программа будет работать по грубым оценкам примерно в 10-20 раз медленнее, чем на ПК. На самом деле это не так страшно, смартфоны с аналогичными мощностями в 2014 году тянули Minecraft PE 0.9.0 .

Надо ещё понимать, что, скорее всего, отдельной видеопамяти нет и 1 ГБ оперативки на самом деле не 1, а меньше, но проверять я этого не буду. Просто буду держать в голове при расчётах, что больше 512 МБ лучше не выделять.

Что будет занимать больше всего памяти? Конечно же, данные о мире. Я не готов городить бесконечный динамически прогружаемый мир, это не для первого раза задачка. Начнём с ограниченного мира, который целиком хранится в одном трёхмерном массиве. Это расточительно по памяти, зато очень просто и быстро. Посчитаем, сколько байт будет занимать 1 блок. Хотелось бы сказать, что 1 байт для хранения ID, и всё, однако я хочу распространять свет и придать блокам разные свойства, поэтому понадобится минимум ещё 1 байт на освещение и ещё надо выделить минимум 1 байт под флаги свойств блока, это нам пригодится позже. Получается, что нужно минимум 3 байта на блок. Целесообразно округлить размер до 4х байт, потому что процессор быстрее получает данные, выровненные по 4 байта.

Теперь надо рассчитать размер мира. Пусть это будет мир размером 512*128*512, где 128 — это высота мира. Получается 128 мегабайт. Это хорошая цифра, можно было взять мир больше, но мне этого достаточно.

Загрузить данные всего мира в массив — это не самая оптимальная, но неплохая идея, а вот загрузить весь мир в видеопамять не прокатит.  На все грани, скорее всего, даже не хватило бы памяти, а если и хватило бы, то это было бы слайд-шоу. Надо реализовать базовую оптимизацию — отсечение смежных граней кубов и динамическую загрузку чанков вокруг игрока.

Перед тем, как рассказывать про алгоритмы оптимизации, стоит объяснять, как я рендерю кубы. Я провел несколько экспериментов, в которых сравнил несколько способов загружать полигоны, чтобы понять, какой будет работать быстрее. Самый примитивный способ — загружать по 3 вершины на каждый треугольник, но это неэффективно из-за дублирующихся вершин. Многие разработчики идут по пути загрузки только уникальных вершин, где отдельно загружается буфер индексов, который позволяет переиспользовать общие вершины. Такой способ действительно дает выигрыш при отрисовке трёхмерных кубов одним мешем. Но я пошёл по другому пути. Я составляю куб из 6 отдельных квадратных полигонов, получается, что мой меш — это не куб, а квадрат. У квадрата слишком мало общих вершин, поэтому загрузка индексов нецелесообразна. Вместо того, чтобы загружать 6 вершин или 4 вершины и индексы, я просто загружаю 4 вершины, а геометрию собираю в геометрическом шейдере. Геометрический шейдер позволяет собирать геометрию по точкам прямо на GPU. Говорят, что геометрический шейдер не самый быстрый, но он дал существенный прирост производительности в моём конкретном случае. Конечно, помимо вершин в буфер загружается ещё немало других данных, таких как нормаль, битовая маска окклюзии и координаты из текстурного атласа, но 24 байта на грань я точно сэкономил.

Собирать меш из квадратных граней удобно, особенно, когда каждая грань описана отдельно. Для этого я создал для каждого блока класс, в котором есть метод для получения меша куба, причем не всего целиком, а только нужных граней. В этот метод передается битовая маска, которая определяет, какие грани куба должны быть в меше. Эта маска рассчитывается отдельно в конструкторе чанка на основании окружающих блоков. Если соседнего блока нет или он прозрачный, то грань рисуется, а если нет, то меш блока не будет содержать эту грань. Помимо меша есть еще метод, который возвращает данные о блоке. Данные хранятся в структуре. Там есть ID блока, флаги состояния, свойства и освещенность. Именно эти 4 байта данных будут храниться в каждой ячейке трехмерного массива.

Теперь поговорим про организацию.

Иерархия такая:

Менеджер мира-> менеджер чанка-> конструктор чанка-> менеджер блоков-> блок.
  • Менеджер мира инициирует процессы в мире.

  • Менеджер чанка определяет координаты чанков, которые должны быть загружены, удалены или перезагружены.

  • Конструктор чанков собирает меш чанка. Он определяет, какие грани надо добавлять в меш, а какие надо отсечь. Он накладывает маску окклюзии.

  • Менеджер блоков, позволяет получить данные блока и меш блока по названию или индексу в массиве.

  • Блок содержит в себе данные, необходимые для формирования меша, и флаги состояния. Именно тут хранятся координаты вершин, нормали, освещённость, флаги состояний и свойств.

Алгоритм динамической загрузки чанков сложнее, чем кажется, он должен составить сразу несколько списков. Например, список чанков, которые должны быть ��агружены, но ещё не загружены, список чанков, которые загружены, но не должны быть загружены, список чанков, которые загружены и должны быть загружены, список чанков, которые просто загружены в данный момент. Причем эти списки должны быть отсортированы, чтобы первыми грузились именно ближние чанки. А ещё список чанков на загрузку и список чанков на выгрузку нельзя опустошить за 1 кадр, это слишком долго, будут сильные фризы. Надо сформировать вторичные списки, которые будут на каждом кадре постепенно рассасывать первичные списки, чтобы на каждом кадре загружалось и удалялось только по 2-8 чанков. Так они будут успевать загружаться за 1 кадр и фризов не будет.

Границу прогруженной области я прячу в тумане. Туман реализован в шейдере. Он просто смешивает цвет текстур с цветом неба. Радиус тумана на 1 чанк меньше радиуса прорисовки.

При установке или удалении блоков надо обновлять не только весь тот чанк, в котором произошло изменение, но и все прилежащие чанки, это нужно, чтобы не образовалось артефактов. Например, дырка на границе чанка или непрогруженный свет. В обычном Майнкрафте, где размер чанка 16*16*16 блоков, чаще всего достаточно перезагрузить только 27 чанков, то есть кубик 3*3*3 чанка, но на Anbernic видны фризы при попытке прогрузить сразу много чанков. Чанки, которые надо перезагрузить, тоже рассасываются постепенно, чтобы избежать фризов, но если перезагружать слишком мало чанков за 1 кадр, то будут видны артефакты, например, дырка в мире на долю секунды. Чтобы этого избежать, надо загружать как  минимум все прилегающие чанки, то есть хотя бы 7 штук за кадр. Для консоли это оказалось сложно, поэтому пришлось уменьшить размер чанка до 8*8*8 блоков и обновлять зону 5*5*5 чанков в месте, где был изменён блок. Область 5*5*5 нужна, потому что свет распространяется на 15 блоков и радиус сферы обновления чанков должен быть не меньше 15 блоков, иначе будут видны глитчи с освещением. На картинке наглядно видно, какой кусок мира подлежит перезагрузке при удалении всего 1 блока.

Дырка
Дырка

Для лучшего восприятия объёма я применил метод затенения углов ambient occlusion. Суть в том, чтобы затемнять углы между блоками, будто туда попадает меньше света. Это дешевый визуальный эффект для придания объема блокам. Алгоритм простой: смотрим на соседние блоки и выбираем маску затемнения для текущей грани. Этот метод вызывается конструктором чанка при формировании меша чанка из отдельных граней. Каждая грань загружается со своей маской, эта маска передаётся в шейдер вместе с данными о вершинах. В шейдере эта маска преобразуется в градиентное затенение к углам. Каждый угол — это 1 бит маски. Так можно затемнять углы в любых комбинациях.

Вот что будет, если сделать в шейдере окклюзию более выраженной:

Гипертрофированное затенение
Гипертрофированное затенение

Еще я добавил модель освещения Фонга. Реализован компонент направленного и компонент рассеянного света. В вершинном буфере есть вектор нормали грани, который нужен для направленного освещения. В шейдере вычисляется косинус угла между вектором нормали грани и вектором солнечных лучей, который определяет степень освещенности грани. Эффект лучше всего виден тут:

Дальше я сделал распространяющийся затухающий свет от неба и от ламп. Алгоритм распространения света оказался самым сложным. Я не использовал оригинальный алгоритм, я придумал свой собственный алгоритм. Это было очень не просто для меня, и алгоритм работает не идеально. Свет распространяется, как вода в лабиринте, он рекурсивно ищет вокруг себя прозрачные блоки с меньшей яркостью и продвигается в них, пока не дойдёт до минимального уровня яркости. Конечно, это очень общее объяснение. На самом деле там очень много подводных камней. Пришлось писать разные алгоритмы для разных сценариев. Например, если разрушить блок лампы, то надо сначала распространить тьму, а только потом свет, причем надо ещё не забыть отделять свет разрушенного источника от "чужого света", который пришёл с другого источника. Если этого не учитывать, то рекурсивный алгоритм уйдёт в разнос и зачистит весь мир или тот свет, который не надо было трогать. При этом свет от неба и свет от ламп — это два абсолютно независящих друг от друга типа света, которые не влияют друг на друга и считаются отдельно в разных классах. Смешивается свет от неба и от ламп только в шейдере.

Алгоритм распространения света от неба разделен на два этапа. Сначала от верхней границы мира вниз распространяется первичный свет. Его особенность в том, что его интенсивность не уменьшается.

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

На этом этапе произошла лишь инициализация света. Теперь надо отдельно обрабатывать разные сценарии. Для этого есть метод  public static void OnBlockChanged(Vector3i coord, BlockData oldBlock), который выбирает сценарий на основании свойств старого и нового блока.

Если старый блок был прозрачный, а новый нет, то надо найти весь свет вокруг этого блока, интенсивность которого меньше, и удалить его. При этом надо удалять аккуратно, чтобы не удалить свет от других источников. Если свет  в соседнем прозрачном блоке интенсивнее, то это чужой свет и удалять его не нужно. При удалении света надо записывать все координаты блоков, в которых был удален свет в список, а потом надо пройтись по этому списку и запустить алгоритм рекурсивного распространения света. Таким образом, удаленная область заполняется светом с учетом света от оставшихся источников.

Если старый блок непрозрачный, а новый прозрачный, то просто распространяем вторичный свет.

Если старый блок прозрачный и новый блок прозрачный, то для света ничего меняется.

Конструктор чанка при сборке меша чанка вызывает метод GetLightFaces(x, y, z, clippingMask), в который передается координата текущего блока и маска видимых граней. Этот метод вернет массив, в котором будет храниться яркость только видимых граней. Эта яркость берётся из соседнего прозрачного блока. Такой метод существует как для света от неба, так и для света от источников.

Распространение света от источников работает аналогично, только сценарии там немного другие.

Если старый блок не был источником, а новый стал, то просто запускается рекурсивное распространение света.

Если старый блок был источником света, а новый нет, то надо удалить весь порождённый от этого источника свет, не удалив свет от других источников, сохранить в список координаты блоков, в которых был удален свет, и пройтись по нему, перераспределяя свет от других источников, если он есть.

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

Если блок стал прозрачным, то рекурсивно распространяем через него свет.

Все эти алгоритмы выполняются с разной скоростью. От сотых долей миллисекунды, до единиц миллисекунд. Для достижения наименьшего времени выполнения пришлось поэкспериментировать.  Значительный прирост производительности дало создание избыточных локальных переменных и предопределение размера списков и словарей предполагаемым значением. Первый трюк работает благодаря оптимальной работе с кэшем процессора, а второй благодаря уменьшенной вероятности аллокаций в ОЗУ.

Свет распространяется в коробку с окном
Свет распространяется в коробку с окном
Тень под платформой
Тень под платформой
Смешивание света
Смешивание света

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

Затем я сделал простенький генератор мира. Тут нет ничего особенного, простой шум Перлина и фильтры по высоте для разных слоёв грунта. Растительность расставляется по выведенному эмпирическим путем принципу.

Слои мира
Слои мира

Для реализации воды пришлось вводить полупрозрачные блоки и добавить в свойства блоков отдельный флаг для жидкостей. Тут надо сделать отступление. Дело в том, что в игре уже есть "прозрачные" блоки, например, листва деревьев и стекло, трава, цветы, грибы, но эти блоки на самом деле не используют альфа-канал. В текстуре альфа-канал есть, но он используется как цветовой ключ, по которому производится отсечение пикселей. В шейдере стоит условие, которое проверяет альфа-канал в текстуре. Если альфа меньше 0.1, то вызывается операция discard, которая отсекает пиксели.

if (TexColor.a < 0.1) discard;

Отсечение — это не прозрачность. Графический процессор просто пропускает вырезанные пиксели при растеризации, поэтому образуется прозрачная область, которую нужно воспринимать как дырку. Получается, что блок стекла или листвы на самом деле не прозрачный, а дырявый. Это важно понимать, потому что дырка не может быть полупрозрачной. Для того, чтобы сделать полупрозрачную воду надо использовать альфа-канал, а с ним все не так просто. При работе с альфа-каналом важно, какой полигон был отрендерен первым. Если отрендерить сначала первый блок воды, а потом второй и посмотреть сквозь второй блок на первый, то первый блок будет видно нормально, потому что второй блок был отрендерен с учётом первого, а вот если посмотреть на второй блок сквозь первый, то второй блок не будет видно, потому что первый блок был отрендерен без учета второго. Для демонстрации временно сделал блоки листвы полупрозрачными, чтобы было видно проблему.

Неправильный порядок рендеринга чанков
Неправильный порядок рендеринга чанков

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

Неправильный порядок рендеринга внутри чанка
Неправильный порядок рендеринга внутри чанка

Я сделал воду единственным полупрозрачным блоком и отключил тест глубины. Можно сказать, что я спрятал слона в комнате, потому что эта проблема больше не видна.

Это не идеальное решение, в некоторых ситуациях можно наткнуться на графические артефакты, связанные с отключенным тестом глубины. На картинке видно, если присмотреться. Если бы это были разные блоки, то было бы сильно заметно.

Слон прячется
Слон прячется

В идеале надо сортировать не по чанкам, а по отдельным граням, но это слишком дорогое удовольствие. Это был один из самых дешёвых и эффективных способов решить проблему. Этот способ дал 80% результата при 20% усилий, в рамках этого проекта мне этого достаточно.

Пожалуй, этого хватит, демонстрационный проект и так вышел из-под контроля.

Такой крупный проект нуждается в организации. Вот мое дерево проекта:

Дерево проекта
Дерево проекта

По задумке в папке Core лежит общий код, который почти без изменений может быть использован для обеих платформ. В папке Graphics — платформозависимые классы и шейдеры (напоминаю, что шейдеры для OpenGL и OpenGL ES отличаются). В папке Resources хранятся текстуры, звуки, шрифты и т.п. В корне проекта находится класс Program.cs, который является точкой входа и  GameConfig.cs, в котором можно настроить различные параметры игры. Например, размер экрана, размер мира, дальность прорисовки, цвет света, время суток, размер чанков, количество прогружаемых чанков на кадр и т.п.

Но эта была организация файлов при разработке. Организация файлов при развертывании может отличаться. Я положил в каталог игры исполняемый файл и создал отдельные каталоги для шейдеров и текстур. Вы можете сделать иначе, главное правильно указать путь к файлам в коде.

Структура каталога с игрой
Структура каталога с игрой

Незабываем создать для этого исполняемого файла скрипт запуска. Он аналогичен тому, который я привел в начале статьи. Отличается только путь и название файла.

#!/bin/bash
cd "$(dirname "$0")/AncraftV3"
chmod +r /dev/input/event1
chmod +x "./AncraftV3" 2>/dev/null
exec "./AncraftV3"

Теперь можно загрузить это все на консоль и пробовать запустить скрипт через графический интерфейс.

Запуск на Anbernic
Запуск на Anbernic

Вряд ли у вас получится собрать крупный проект с первого раза. Отладка таких программ не очень удобна, и вы постоянно будете сталкиваться с проблемами. Советую начать с тестового проекта с треугольником. Если он будет работать, то можно двигаться дальше. Следите за целостностью файлов и логируйте свои программы. Например, можно выводить логи в текстовый файл. Так вы сможете понять, попыталась ли вообще ваша программа запуститься и какая ошибка привела к вылету. Самая частая проблема, с которой я сталкивался — это битые шейдеры, которые вызывали исключения при попытке их скомпилировать. Немного терпения и последовательности при решении проблем, и все получится.

Надеюсь, статья получилась не слишком сумбурной. Это был интересный опыт для меня, но вынужден признать, что разработка игр таким образом очень трудоёмкая и утомительная. Тем, кто хочет попроще, советую присмотреться к Godot 3.5. В этой версии можно собирать проект под Linux ARM64, но, к сожалению, не на C#, а на GDScript. У моего способа есть 2 существенных недостатка. Высокая трудоёмкость и низкая переносимость из-за ручного развёртывания.

Скачать архив с исходниками и библиотеку можно с диска: https://disk.yandex.ru/d/hGrd8RNihFDaGQ

Если вы нашли неточности или знаете, как решить проблему с переносимостью на аналогичные консоли, то напишите об этом.