Порт dagon:imgui на SDL GPU

Рендер Dagon 2 еще не завершен, но я решил уже сейчас наскоком закрыть одну из самых нестандартных задач всего проекта — написать бэкенд ImGui для SDL GPU. Что интересно, эта работа вскрыла очередную проблему с фрейм-таймингом, на которую я раньше не обращал внимания.

ImGui — потрясающе хорошо спроектированная библиотека. Она рисует интерфейсы при помощи абстрактного низкоуровневого воркфлоу, путем формирования списка примитивов. Чтобы фактически вывести графику ImGui на экран, нужно написать код, который в реальном времени передает эти данные в графический API. Это не самая тривиальная задача, но, как ни парадоксально, для SDL GPU она решается даже несколько проще, чем для OpenGL. Меня вообще этот API зацепил именно тем, что он избавляет от неудобных и довольно запутанных идиом OpenGL, не порождая при этом кучу новых, как это делают другие. На SDL многие распространенные конструкции выглядят в разы короче, чем на OpenGL или Direct3D (и я уж молчу про чистый Vulkan). В общем, оказалось, что ImGui отлично дружит с SDL GPU.

Перебор примитивов, полученных через igGetDrawData, и их трансляция в вызовы отрисовки — довольно простой процесс. Управление вершинным и индексным буферами под это дело уже сложнее: особенность в том, что предельное количество входящих примитивов ImGui заранее не известно, поэтому буферы должны быть динамическими. Точнее, нужно их динамически пересоздавать с запасом, если текущего объема не хватает. Когда пишешь такое, ощущение как от системной разработки — кажется, будто создаешь какой-то свой X11 😅

Но мало написать графический бэкенд, нужна еще прослойка, обрабатывающая ввод из SDL, иначе интерфейс не будет реагировать на события мыши и нажатия клавиш. Ее тоже пришлось написать самостоятельно, чтобы не трогать внутренности cimgui.dll — в Dagon 1.0 я полагался на встроенный в библиотеку бэкенд под SDL2. Фактически эта задача сводится к переводу событий и клавиатурных кодов SDL в формат ImGui, а также небольшому хаку с функциями TextInput для того, чтобы правильно работал ввод текста:

if (io.WantTextInput)
{
    if (!SDL_TextInputActive(application.window))
        SDL_StartTextInput(application.window);
}
else
{
    if (SDL_TextInputActive(application.window))
        SDL_StopTextInput(application.window);
}

Наконец, последнее, что я сделал — слегка изменил логику рендера, а именно вынес получение текстуры свопчейна из PresentPass в Renderer, в начало кадра (это нужно, чтобы можно было рендерить в задний буфер несколькими проходами подряд). Не поверите, но это единственное, что пришлось изменить в ядре движка, чтобы добавить поддержку ImGui! Все остальное — исключительно надстройки.

Я было обрадовался, но потом заметил инпут-лаг (причем в Dagon 1.x он тоже есть, хотя и не такой сильный). Когда перетаскиваешь окошко ImGui, видно, что курсор его опережает, хотя, по идее, не должен, если движение синхронизировано со вводом. Это напрямую связано с VSync. Непосредственно в игре задержка ввода не так заметна, как в GUI, но она есть, и в некоторых случаях из-за этого возникает что-то сродни морской болезни. Вокруг этой темы сломано немало копий, холивары о том, зло ли VSync, бытуют еще чуть ли не с 90-х. Иногда, при конфликте с оконным менеджером ОС, включение VSync может привести к еще более неприятному артефакту в виде статтеринга, так что я все же склоняюсь к мысли, что классический VSync больше мешает, чем помогает.

Для решения этой проблемы в современных API придумали Mailbox. Это разновидность тройной буферизации, которая позволяет выводить кадры с минимальной задержкой на синхронизацию с монитором. На моей системе этот режим показывает себя на удивление хорошо, стабилизируя тайминг кадра так, что реакция на ввод срабатывает молниеносно. Естественно, я его добавил в Dagon 2. Он включается опцией vsync: 2 в settings.conf.

Sponza в Dagon 2

Скриншоты сцены Sponza, загруженной из файла DAF:

Обновления

Dagon 1.6.0

Добавлено расширение dagon:server для создания игровых серверов.
Исправлен баг в методе Application.takeScreenshot. Исправлен вывод в лог первой записи (версии Dagon) при сохранении лога в файл.
Движок теперь использует dlib 1.6.0.

Minicrypto 0.5.0

Добавлены модули minicrypto.base64 (реализация кодировки Base64) и minicrypto.hex (конвертер байтовых массивов в шестнадцатиричный текст и обратно). Эти фичи не имеют прямого отношения к криптографии, но полезны для сохранения ключей шифрования и прочих бинарных данных в текстовом виде, что необходимо для копипаста, при использовании в JSON, в конфигах и т.д.

Великий и ужасный cast

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

Во-первых, без приведения типов невозможна низкоуровневая работа с железом (точнее, с драйверами). Если вы хотите передать данные по сети, у вас нет иного выбора, кроме как преобразовать данные в цепочку байт — ничего другого сеть не понимает. Если вы хотите загрузить данные в видеопамять для использования при рендеринге или иных GPU-вычислениях, то это также должен быть простой байтовый буфер — видеодрайвер тоже ничего не знает о системе типов вашего языка.

Во-вторых, тип при вычислении может не совпадать с типом при хранении. Например, пиксели избражений обычно хранятся в памяти как трех- или четырехканальные 8-битные целые. Но это слишком низкая точность информации для вычислений, поэтому функции обработки изображений чаще всего пишутся на float’ах: целочисленное представление пикселя конвертируется в вещественное на входе функции и наоборот — на выходе (именно так, кстати, работает GPU). Аналогично обрабатывается и звук.

В-третьих, каст нужен для преодоления двусмысленности (ambiguity), присущей семантике языков. Например, если в бинарном операторе даны операнды двух разных типов, то компилятор может не пытаться угадывать, какую семантику имел в виду программист, и оставить это на его совести, требуя явного приведения одного типа к другому.

В D, как и в C++, есть несколько семантик приведения типов. Отличие в том, что все они записываются одинаково — ключевым словом cast. Поэтому очень важно понимать семантику приведения, релизуемую компилятором в различных ситуациях.

(далее…)

Обновления

Dagon 1.5.0

Добавлены расширения dagon:network и dagon:security, реализующие, соответственно, сетевой интерфейс на базе UDP/ENet и безопасный транспортный протокол.
Функции для работы с кубическими картами из dagon.graphics.texproc вынесены в отдельный модуль dagon.graphics.cubemap.
Метод Application.takeScreenshot теперь не использует сборщик мусора.

dlib 1.6.0

Добавлен новый пакет dlib.time с реализацией кроссплатформенного интерфейса к системным функциям времени-даты. Временные метки (DateTime) имеют точность до целых секунд и предназначены для относительно редких событий (логирование, онлайн-переписка, календарные события и т.д.). Для более точного измерения времени следует использовать наносекундный счетчик в std.datetime.stopwatch, либо специализированные таймеры внешних библиотек (например, таймер SDL).
Добавлен новый модуль dlib.container.sorting с реализациями алгоритмов сортировки выбором и вставками. Они лучше всего подходят для сортировки небольших массивов нечасто обновляющихся данных в реальном времени. Например, если массив уже отсортирован, то функция сортировки вставками insertionSort будет в сто раз (!) быстрее, чем std.algorithm.sort, что идеально для игр.
Методы встроенного мьютекса Mutex помечены как @nogc.

Не используйте std.algorithm.sort!

Точнее, не используйте бездумно для всего. Я уже как-то писал о неэффективности std.variant, и вот еще один казус: стандартная функция сортировки в Phobos более чем в 100 раз медленнее, чем сортировка вставками (insertion sort) для маленьких массивов. Вот результат бенчмарка для массива из 6 случайных элементов и миллиона вызовов каждой функции:

std.algorithm.sort: 70 ms, 200 μs, and 6 hnsecs total
Selection sort: 572 μs and 6 hnsecs total
Insertion sort: 570 μs and 8 hnsecs total

На массиве из 50 элементов сортировка выбором уже проигрывает, но вставки по-прежнему намного быстрее:

std.algorithm.sort: 494 ms, 694 μs, and 6 hnsecs total
Selection sort: 638 ms and 800 μs total
Insertion sort: 307 ms, 370 μs, and 2 hnsecs total

На массиве из 100 элементов std.algorithm.sort и сортировка вставками начинают показывать примерно одинаковую производительность:

std.algorithm.sort: 1 sec, 525 ms, 667 μs, and 3 hnsecs total
Selection sort: 2 secs, 847 ms, 63 μs, and 7 hnsecs total
Insertion sort: 1 sec, 552 ms, 324 μs, and 9 hnsecs total

Что интересно, при 200 элементах сортировка вставками снова вырывается вперед:

std.algorithm.sort: 6 secs, 138 ms, 993 μs, and 8 hnsecs total
Selection sort: 11 secs, 592 ms, 87 μs, and 7 hnsecs total
Insertion sort: 5 secs, 193 ms, 42 μs, and 1 hnsec total

При 300 элементах и больше std.algorithm.sort уже эффективнее:

std.algorithm sort: 9 secs, 178 ms, 920 μs, and 1 hnsec total
Selection sort: 26 secs, 875 ms, 201 μs, and 1 hnsec total
Insertion sort: 11 secs, 783 ms, 883 μs, and 5 hnsecs total

Из этого вывод: если нужно сортировать совсем маленькие данные (такие, как турнирная таблица в игре), то кастомная сортировка подойдет намного лучше, чем стандартная.

STP-D — защищенный протокол для игр на D

Идея с шифрованием из коробки реализовалась буквально за неделю: я решил портировать Monocypher на D, что вылилось в создание пакета Minicrypto и расширения dagon:security, о которых сегодня и расскажу. Вкратце — это позволит играм на Dagon (да и вообще практически любым приложениям) использовать защищенное UDP-соединение клиента и сервера, причем без зависимости от тяжелых протоколов типа DTLS или SRTP.

(далее…)

dagon:network — асинхронный UDP-клиент для игр

В Dagon грядет расширение для создания сетевых игр! Я решил интегрировать библиотеку ENet и написать поверх нее легковесный клиент, при помощи которого игра сможет взаимодействовать с UDP-сервером. Причем это работает через менеджер событий и асинхронную систему обмена сообщениями (dagon.core.messaging), о которой я писал в соответствующем посте. Это дает сразу два преимущества: сетевой клиент не блокирует игровую логику (поскольку работает в собственном потоке), и гарантируется порядок отправки сообщений (сообщения добавляются в SPSC-очередь).

Выглядит это так:

import dagon.ext.network;

class TestScene: Scene
{
    MyGame game;
    NetworkClient netClient;

    this(MyGame game)
    {
        super(game);
        this.game = game;
    }

    override void afterLoad()
    {
        // Инициализация игры...

        netClient = New!NetworkClient("GameClient",
            game.networkManager, "XXX.XXX.XXX.XXX", 1234, this);
        netClient.run();

        eventManager.messageBroker.enabled = true;
    }

    override void onMessage(
        int domain, string sender, string message, void* payload)
    {
        if (sender == "GameClient")
        {
            logInfo("[Server] ", message);
        }
    }

    override void onKeyDown(int key)
    {
        send("GameClient", "Hello, World!");
    }
}

class MyGame: Game
{
    NetworkManager networkManager;

    this(uint w, uint h, bool fullscreen, string title, string[] args)
    {
        super(w, h, fullscreen, title, args);
        networkManager = New!NetworkManager(this);
        currentScene = New!TestScene(this);
    }
}

Реализация полностью потоко-безопасна — хост и пир ENet, ассоциированные с NetworkClient, изолированы в рамках потока, и цикл enet_host_service у него тоже свой собственный.

На данный момент NetworkClient работает только со строками (как, собственно, и сам брокер сообщений). Для поддержки бинарных данных, видимо, придется чуть изменить архитектуру. Кроме того, хочется, чтобы было из коробки шифрование, так что буду копать в сторону AEAD-алгоритмов.

Обновления

Dagon 1.4.0

Добавлена поддержка сжатия текстур в BC4 и BC7, бэкпортированная из Dagon 2. Компрессор BC7 является D-портом библиотеки bc7enc Рича Гелдриха, компрессор BC4 написан с нуля.
Модуль dagon.resource.sdlimage объединен с dagon.resource.texture.
prefilterCubemap теперь принимает опциональный параметр lumaScale, коэффициент яркости для входных значений кубмапа.
Исправлен баг, из-за которого свойство Application.fullscreen не обновлялось по конфигу.

dlib 1.5.1

Небольшой багфикс-релиз dlib 1.5, в котором был исправлен неприятнейший баг декодера JSON — пустые объекты и массивы интерпретировались как узлы JSONType.Null.

Chillwave Drive — новые машины

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