Dagon 0.30.0 и 0.31.0

Выпустил подряд две версии движка. В ядро Dagon внесен фреймворк многопоточности и обмена сообщениями, о котором я подробно писал в предыдущем посте. EventManager.userEventQueue переименовано в EventManager.outboxEventQueue, EventManager.numUserEvents — в EventManager.numOutboxEvents. Также теперь рекомендуется использовать EventManager.queueEvent вместо EventManager.addUserEvent, EventManager.queueFileChangeEvent вместо EventManager.generateFileChangeEvent, EventManager.queueLogEvent вместо EventManager.asyncLog.

Заметно улучшен пакет dagon.collision, хотя он пока и далек от продакшн-уровня. Исправлены баги в модуле BVH, добавлена реализация GeomTriangle.boundingBox, а также экспериментальный алгоритм проверки столкновений GJK (dagon.collision.gjk). EPA пока не поддерживается, так что функция gjkTest не возвращает информацию о контакте — основным алгоритмом проверки столкновений остается MPR. Метод CollisionShape.supportPointGlobal теперь просто CollisionShape.supportPoint.

В Dagon 0.31.0 я продолжил улучшение пакета core. Добавлены свойства Application.path и Application.directory — соответственно, полный путь к исполняемому файлу и папка, в которой он лежит. Под Windows доступно свойство Application.hwnd для получения дескриптора окна игры. VFS теперь монтирует в качестве последнего источника данных папку, где хранится приложение, а не рабочую папку. Благодаря этому можно в командной строке запускать приложение не из текущей папки.

Экспериментальная фича: поддержка ввода с графических планшетов (пока только под Windows через Wintab). Абстрактный интерфейс InputDevice для добавления в EventManager кастомных устройств ввода. Новые типы событий EventType.PenMotion, EventType.JoystickAxisMotion, EventType.LocaleChange.

В deferred-рендер добавлена поддержка перспективных теневых карт (PSM) для конусных источников света.

Dagon 0.31.0 является последней версией, использующей OpenGL 4.0 — со следующей движок переходит на 4.3, что позволит добавить поддержку вычислительных шейдеров.

Конкурентное программирование в Dagon

Идея добавить в EventManager поддержку конкурентности/асинхронности не давала мне покоя еще со времен DGL, и вот, наконец, мне это удалось. Точнее, асинхронный слой удобно лег поверх стандартной событийной шины, и для этого не пришлось менять в ней практически ничего.

Перед тем, как перейти к деталям, объясню в двух словах, как работает менеджер событий, и чем проблематична его синхронная природа. Менеджер событий служит, главным образом, прослойкой между SDL и объектной структурой приложения. SDL предполагает единственного слушателя, который опрашивает события через SDL_PollEvent — менеджер событий Dagon агрегирует события SDL в собственную очередь, позволяя неограниченному множеству других объектов-слушателей (наследующих от EventListener) опрашивать события и реагировать на них путем динамической диспетчеризации.

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

На помощь приходит многопоточность (которую еще называют конкурентностью, если речь идет о разделении времени, а не о строго параллельном выполнении потоков, как на GPU). Если выполнять блокирующие задачи в отдельном потоке, то игра не будет лагать, пользователь ничего не заметит.

Потоки создавать легко, и они полностью решают проблему синхронности, но они приносят с собой другую — resource contention (конкуренцию за ресурс). Это явление, когда два и более потоков могут одновременно читать и записывать одни и те же данные в памяти или аналогично использовать тот или иной общий ресурс (файл, какой-либо интерфейс и т.д.). Это приводит к состоянию гонки, когда результат программы зависит от непредсказуемого порядка выполнения операций различными потоками. А без этого потоки почти бесполезны, если только вы не решаете идеально распараллеливаемую задачу, когда каждый поток читает неизменяемые данные и пишет в свой приватный участок памяти. Игровую логику распараллелить непросто — состояние в играх, как правило, общее, его не поделишь. Для решения этой проблемы есть системные блокировки, мьютексы, которые позволяют «приватизировать» чтение и запись в определенном участке кода для одного потока, заблокировав остальные, но это может приводить к другим трудно решаемым проблемам вроде взаимных блокировок. К тому же мьютексы не бесплатны, и оверхед на блокировки, если ими злоупотреблять, может запросто перекрыть выгоду от использования потоков. Многопоточность — это настоящее минное поле!

К счастью, производители процессоров и тут предусмотрели решение — атомарные операции. Это такие операции, которые гарантированно выполняются целиком, не прерываясь другими потоками. Атомарное чтение гарантирует, что переменная не будет перезаписываться, пока ее читают. Атомарная запись — что другие потоки не будут читать переменную, пока она записывается. На x86 многие операции атомарны, такие, как 32-битное присваивание, но в общем случае на это полагаться нельзя, так как атомарность почти никогда не гарантируется на уровне ЯП — нужно использовать специальные интринсики, которые транслируются в атомарные инструкции. В D они предоставляются модулем core.atomic.

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

В Dagon я реализовал межпоточную систему обмена сообщениями на основе SPSC-очередей. Фишка в том, что если очередь на кольцевом буфере всегда читается одним потоком и записывается другим (не больше!), она реализуется на атомиках как lock-free и wait-free алгоритм — то есть, операции над ней не блокируют поток и завершаются за детерминированное время, что идеально для реалтайма. Процесс аналогичен работе почтовой службы. Поток — вернее, объект Service, работающий в своем потоке — имеет две очереди, похожие на два почтовых ящика: для входящих сообщений (inbox) и исходящих (outbox). Когда сервис хочет отправить сообщение, он добавляет новый элемент в свою outbox-очередь. Специальный брокер в главном потоке, как почтальон, забирает сообщение и переправляет его в inbox-очередь сервиса-адресата. Тот, в свою очередь, поллит (то есть, опрашивает) свой inbox и реагирует на входящие сообщения.

Особенность моей реализации — она «дружит» с EventManager, в том смысле, что события из стандартной шины уходят в брокер, а сообщения от сервисов, если они адресованы синхронным компонентам приложения, приходят из бокера в стандартную шину. Помимо сообщений, поддерживаются задачи — можно асинхронно делегировать сервисам любой метод путем создания события типа EventType.Task. Брокер в этом случае выступает планировщиком, выбирая один из сервисов-воркеров для выполнения задачи по принципу round-robin (то есть, всем по кругу). Сервисы же могут выполнить свой метод синхронно, выставив задачу стандартной шине.

Адресация осуществляется при помощи строковых имен — любой синхронный EventListener и асинхронный сервис может иметь уникальный (или неуникальный) адрес для получения сообщений. Маршрутизация (от шины к брокеру и обратно) осуществляется при помощи целочисленной метки, которую я назвал доменом (domain). Если событие имеет отрицательный домен, то оно предназначено для сервиса, если положительный — для EventListener. Благодаря такому разделению системе легко разобраться, что куда отправлять, брокер при отправке может отсеивать определенные события, чтобы не создавать feedback loop. Использование знака домена лежит в основе математической логики маршрутизации — если произведение доменов положительно, то события идут в одном направлении, и наоборот. Нулевой домен трактуется как циркуляр, то есть, событие, предназначенное для всех систем. События ввода всегда являются циркулярами, сообщения могут ими быть, задачи — не могут (то есть, они идут либо в шину, либо в брокер).

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

Примеры

Простой сервис, который логирует все входящие сообщения и отвечает одним и тем же текстом:

class Responder: Service
{
    this(string address, MessageBroker broker, Owner owner)
    {
        super(address, broker, owner);
    }

    override void onMessage(
        uint domain, string sender, string message, void* payload)
    {
        queueLog(LogLevel.Info, message);

        send(sender, "Hi!", null, MessageDomain.MainThread);
    }
}

class TestScene: Scene
{
    Responder responder;

    override void afterLoad()
    {
        // важно для получения сообщений!
        address = "Scene";

        responder =
            New!Responder("Responder", eventManager.messageBroker, this);
        responder.run();
        
        // брокер по умолчанию выключен
        eventManager.messageBroker.enabled = true;
    }

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

    override void onMessage(
        int domain, string sender, string message, void* payload)
    {
        logInfo("Scene received from ", sender, ": ", message);
    }
}

Пример из реальной жизни — простой сетевой клиент, который умеет только отправлять запросы через блокирующий сокет. Сообщения одному и тому же сервису обрабатываются по очереди, так что конфликт за порт исключен.

import std.socket: TcpSocket, InternetAddress;

class NetworkClient: Service
{
    string host;
    ushort port;

    this(string address, MessageBroker broker, Owner owner,
        string host, ushort port)
    {
        super(address, broker, owner);
        this.host = host;
        this.port = port;
    }

    override void onMessage(
        int domain, string sender, string message, void* payload)
    {
        auto soc = new TcpSocket();
        try
        {
            soc.connect(new InternetAddress(host, port));
            soc.send(message);
            soc.close();
        }
        catch(Exception e)
        {
            queueLog(LogLevel.Warning, e.msg);
        }
    }
}

Воркеры — сервисы, специально предназначенные для выполнения задач:

class TestScene: Scene
{
    Worker worker1, worker2;

    override void afterLoad()
    {
        address = "Scene";

        worker1 = New!Worker("Worker1", eventManager.messageBroker, this);
        worker2 = New!Worker("Worker2", eventManager.messageBroker, this);

        worker1.run();
        worker2.run();

        eventManager.messageBroker.enabled = true;
    }

    void doSomethingThatBlocks(Object obj, void* payload)
    {
        queueLog(LogLevel.Info, "doSomethingThatBlocks");
    }

    override void onKeyDown(int key)
    {
        queueTask(&doSomethingThatBlocks);
    }
}

В этих примерах используется queueLog вместо обычных функций log*, потому что логгер потоко-небезопасен — для асинхронного логирования я еще несколько релизов назад предусмотрел альтернативный механизм. Вместо прямого логирования следует добавлять событие лога в очередь, и EventManager затем его обработает, когда сможет, безопасно перенаправив сообщение логгеру. Недостатком этого является невозможность вариативного вызова с несколькими параметрами — можно передать только одну строку, но это лучше, чем ничего.

Обновления

Dagon 0.29.0

В очередной версии Dagon добавлены эффекты ретро-рендеринга — снаппинг вершин (SimpleRenderPass.retroVertexSnapping) и пикселизация (PresentRenderer.pixelization, PresentRenderer.pixelSize). SimpleRenderer теперь поддерживает свечение, туман и фоновые объекты. Исправлен баг с неправильным рендерингом упрощенной тени в SimpleRenderer.

В модуль dagon.graphics.texture добавлены функции конвертации текстурных форматов между различными API: dxgiFormatToGLFormat, vkFormatToGLFormat, glFormatToVkFormat.

Исправлены некоторые важные баги, в том числе интерполяция кадров в GLTFPose и GLTFBlendedPose, а также, благодаря переходу на новую версию dlib, очень неприятный баг с повреждением памяти в загрузчике glTF.

dlib 1.3.3

Багфикс-релиз, исправляющий повреждение памяти в декодере JSON из-за неправильного поведения лексера.

PN Reloaded 2.7.0

К этой версии редактора прилагается плагин PNScript, реализующий поддержку скриптов на JavaScript (на основе Node.js). Скрипты работают как текстовые фильтры, с их помощью можно реализовать форматирование, умный поиск, деобфускацию, различные алгоритмы анализа данных и т.д. Можно устанавливать NPM-пакеты, что дает практически неограниченные возможности обработки текстов. Автоматизация самого редактора (создание макросов) пока не поддерживается.

Также добавлена опция «Load ASCII files as UTF-8».

Сборку можно скачать на странице релиза.

Dagon 0.28.0

В этом году обновления Dagon выходят одно за другим — дело в том, что я решил увеличить частоту релизов, так как добавляемые улучшения мало связаны между собой и обычно не ломают обратную совместимость. Движок постепенно приближается к версии 1.0, после чего набор базовых фич будет стабилизирован, и я уже не буду вносить серьезных изменений в API.

В новом релизе добавлен оператор тональной компрессии Khronos PBR Neutral, а также новый extra-шейдер OceanShader, реализующий волны Герстнера. Исправлен баг с передачей неправильного буфера глубины в фильтр Depth of Field. Главный конфиг приложения (settings.conf) теперь поддерживает опции рендеринга — см. шаблон конфига в репозитории. Добавлено свойство PositionSync.mask для взвешенной/выборочной привязки к осям.

Шаблон проекта DUB (init-exec) теперь генерирует файлы settings.conf, input.conf и locales/en_US.lang.

До этого еще выходил небольшой патч Dagon 0.27.1, в котором я исправил баги в загрузчике KTX/KTX2 и добавил поддержку сохранения текстур в KTX2 (функция saveTextureToKTX2).

Шейдер океана

В следующей версии Dagon будет новый extra-шейдер OceanShader, реализующий волны Герстнера. Волны смещаются так, что объект воды можно двигать в плоскости XZ вместе с камерой — получается эффект бесконечного океана.

Пример:

auto oceanMesh = New!ShapePlane(150, 150, 100, assetManager);

auto waterMaterial = addMaterial();
waterMaterial.roughnessFactor = 0.0f;
waterMaterial.blendMode = Transparent;
waterMaterial.useCulling = false;
waterMaterial.textureScale = Vector2f(0.2f, 0.2f);
waterMaterial.shader = New!OceanShader(assetManager);

auto eWaterPlane = addEntity();
eWaterPlane.drawable = oceanMesh;
eWaterPlane.material = waterMaterial;
auto psyncWater = New!PositionSync(eventManager, eWaterPlane, camera);
psyncWater.mask = Vector3f(1.0f, 0.0f, 1.0f);

Dagon 0.27.0

Выпустил новую версию движка. В Dagon 0.27 наконец-то появились тени от позиционных источников света (то есть, для всех, кроме Sun) — это реализовано техникой двойного параболоида, которая значительно эффективнее классического подхода с теневой кубической картой. Чтобы включить тень, ничего особенного делать не надо, просто light.shadowEnabled = true;.

Также добавил префильтрацию кубических карт — свертку с использованием GGX BRDF под различные значения шероховатости. Чтобы сконвертировать равнопромежуточную карту в кубическую, а затем отфильтровать, нужно сделать так:

uint resolution = 1024;
Texture cubemap = generateCubemap(resolution, aEnvmap.texture, null);
Texture prefilteredCubemap = prefilterCubemap(resolution, cubemap, assetManager);
Delete(cubemap);

Появилась поддержка анизотропной фильтрации текстур (если поддерживается расширение GL_EXT_texture_filter_anisotropic). Включается следующим образом:

texture.useAnisotropicFiltering = true;
texture.anisotropy = texture.maxAnisotropy;

Добавлена поддержка поля extras для большинства объектов в загрузчике glTF (кроме материалов и текстур).

Еще одна новая фича — поддержка локализации приложений (dagon.core.i18n). Локали — файлы *.lang — загружаются из папки locales. Например, чтобы добавить русскую локаль, нужно добавить файл locales/ru_RU.lang.

Синтаксис файлов точно такой же, как у конфигов. Например, можно сделать так:

hello_world: "Привет, мир!";

В приложении:

string text = application.translate("hello_world");

Приложение сначала пытается загрузить locales/en_US.lang, затем локализацию, выбранную в зависимости от текущего системного языка и региона, перезаписывая дефолтные английские значения. Либо пользователь может явно указать нужную ему локаль в settings.conf:

locale: "ru_RU";

Тени от позиционных источников света

Фича, которую я запланировал на Trello очень давно и сумел реализовал только сейчас. Теперь в Dagon любой позиционный источник света (AreaSphere, AreaTube, Spot) может иметь тени — это реализовано техникой двойного параболоида (dual paraboloid shadow mapping), где геометрия проецируется на две текстуры глубины, с двух сторон относительно источника света.

Dagon 0.26.0

На днях вышла новая версия движка! Как я уже писал ранее, в этой версии все загрузчики ресурсов в Dagon используют единую виртуальную файловую систему — Application.vfs. В связи с этим класс VirtualFileSystem был значительно актуализирован и расширен. В частности, теперь движок из коробки поддерживает загрузку ресурсов из папки с данными (C:\Users\AppData\Roaming\<appDataFolder> под Windows, где <appDataFolder> — кастомное имя папки, которое указывается при создании приложения Dagon). Кроме того, главный конфиг приложения (ранее Game.config) переехал в класс Application. Появился новый метод Application.showConsoleWindow для переключения видимости окна консоли под Windows.

В EventManager наконец-то доведена до ума проверка однократного нажатия и отпускания клавиш/кнопок мыши/кнопок контроллера (EventManager.keyDown, EventManager.keyUp и т.д.). Эта функциональность добавляет небольшой оверхед, поэтому она по умолчанию отключена — нужно ее включать свойством EventManager.trackUpDownState. Как следствие, InputManager.getButtonUp и InputManager.getButtonDown теперь также работают корректно. Добавлено свойство EventManager.application, чтобы можно было получить доступ к объекту приложения из менеджера событий.

Добавлен новый аппаратно-ускоряемый генератор кубических карт — функция dagon.graphics.texproc.generateCubemap, эффективная замена методу Texture.createFromEquirectangularMap. Эта функция конвертирует равнопромежуточные карты окружения в кубические, что позволяет избежать некоторых неприятных артефактов первых. Благодаря переносу на GPU, теперь эта процедура выполняется очень быстро. Данный компонент будет развиваться и дальше — в следующих версиях будет поддержка префильтрации, о которой я писал в предыдущем посте.

Предрассчитанная таблица BRDF LUT теперь поставляется вместе с движком в папке data/__internal/textures/brdf.dds. Эта текстура загружается автоматически и доступна как DeferredRenderer.brdf. В случае использования отложенного рендера, рекомендуется активировать ее следующим образом:

environment.ambientBRDF = game.deferredRenderer.brdf;

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

GLTFPose и GLTFBlendedPose теперь обновляют матрицы трансформации объектов Entity, ассоциированных с костями glTF. Это позволяет, например, использовать Entity.positionAbsolute для получения позиции кости в мировом пространстве.

Добавлен новый модуль dagon.extra.verlet с реализацией position-based динамики для симуляции веревок и цепей.

Префильтрация кубических карт

В дополнение к простому конвертеру из равнопромежуточных карт окружения в кубические карты, в Dagon появилась поддержка предварительной фильтрации (prefiltering) — то есть, свертки кубических карт с использованием GGX BRDF под различные значения шероховатости. Результаты свертки сохраняются в mip-уровнях. Этот процесс является одной из ключевых оптимизаций PBR, поскольку избавляет от необходимости вычислять интеграл облученности в реальном времени. Префильтрация осуществляется очень быстро на GPU, поэтому ее, в принципе, можно делать каждый раз при запуске приложения (но лучше, конечно, однократно с кэшированием в файл — скоро добавлю и такую возможность). Преимуществом является то, что теперь для этого необязательно использовать сторонние утилиты типа IBLBaker — движок все делает сам.

Реализация основана на классическом методе (выборка по последовательности ван дер Корпута-Хаммерсли), описанном в статье «Real Shading in Unreal Engine 4» (Брайан Карис, Epic Games, SIGGRAPH 2013).

Виртуальная файловая система

В Dagon уже достаточно давно существовала поддержка VFS, но до сих пор все загрузчики ресурсов использовали отдельные объекты VirtualFileSystem, поэтому не было простой возможности монтировать общие для всей логики движка пути. Все изменится в грядущем Dagon 0.26, где будет единая VFS на все случаи жизни — Application.vfs. Пользовательский API при этом останется без изменений.

VFS позволяет хранить ресурсы игры в различных папках (и даже в архивах), предоставляя унифицированный интерфейс для доступа к ним. По умолчанию в Dagon теперь монтируются рабочая папка игры и папка с данными (C:\Users\AppData\Roaming\<appDataFolder> под Windows, где <appDataFolder> — кастомное имя папки). Последний примонтированный источник является наиболее приоритетным — то есть, в данном случае файл будет сначала искаться в appDataFolder, а потом — в рабочей папке. Преимуществом такого подхода является упрощение моддинга, возможность замены любого ресурса без риска сломать оригинальную игру, вплоть до создания тотальных конверсий.