dagon:audio

В новой версии Dagon будет встроенный звуковой движок на основе SoLoud — расширение dagon:audio. Поясню для тех, кто не знает: SoLoud — это библиотека-микшер с поддержкой 3D-звука и базовых потоковых форматов (WAV, MP3, OGG, FLAC), а также трекерных форматов через OpenMPT. Есть частотные фильтры и встроенные синтезаторы — в частности, текстово-речевой преобразователь, SFXR, эмулятор VIC-20 и генератор разных видов шума. SoLoud поддерживает множество бэкендов, и по дефолту работает на основе SDL2, как и сам Dagon (пользователь, однако, может выбрать конкретный бэкенд через опцию audio.backend в settings.conf). Короче говоря, это своего рода «FMOD от мира OpenSource». Особо отмечу удобный API и, более того, прямо из коробки — обертка для D, позволяющая писать в объектно-ориентированном стиле.

SoLoud можно было и раньше использовать в приложениях Dagon с использованием BindBC-SoLoud, но dagon:audio упрощает работу со звуком в играх еще больше, предоставляя удобный механизм привязки 3D-слушателя и пространственных звуков SoLoud к объектам движка. Для этого я ввел менеджер AudioManager и компонент SoundComponent:

class MyGame: Game
{
    AudioManager audioManager;

    this(uint windowWidth, uint windowHeight, bool fullscreen, 
        string title, string[] args)
    {
        super(windowWidth, windowHeight, fullscreen, title, args);
        audioManager = New!AudioManager(this);
        currentScene = New!TestScene(this);
    }
}

class TestScene: Scene
{
    AudioManager audio;
    SoundComponent cubeSpeaker;
    WavStream music;

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

    override void afterLoad()
    {
        Camera camera = addCamera();
        camera.position = Vector3f(0.0f, 1.8f, 5.0f);
        game.renderer.activeCamera = camera;

        audio.listener = camera;

        auto eCube = addEntity();
        eCube.position = Vector3f(0, 1, 0);

        cubeSpeaker = audio.addSoundTo(eCube);
        music = audio.loadMusic("data/music/song.mp3");
        cubeSpeaker.play(music);
    }

    override void onUpdate(Time t)
    {
        audio.update(t);
    }
}

Позиция и ориентация слушателя и позиция источника звука обновляются автоматически в update.

Есть также класс PlaylistPlayer для управления плейлистами. Он упрощает загрузку множества аудиофайлов из папки и переключение между ними — полезно для автоматического воспроизведения фоновых мелодий одну за другой.

PlaylistPlayer pl = audio.addPlaylistPlayer();
pl.addTracksFromDirectory("data/music");
pl.play(0);

SoundComponent и PlaylistPlayer совместимы между собой:

cubeSpeaker.playlistPlayer = pl;
cubeSpeaker.play(0);

Арена-аллокатор в Dagon

Помимо встроенных в dlib механизмов управления памятью (New/Delete, ownership), Dagon теперь поддерживает арены (dagon.core.arena) — специально для случаев, когда ownership не подходит (в основном, это операции над строками). Арена — это аллокатор, который запрашивает память у системы одним большим блоком и размещает в нем объекты последовательно, пока он не заполнится. После этого запрашивает еще один блок и т.д. В моей реализации нет возможности удалять объекты индивидуально — память арены высвобождается только при удалении самой арены. Однако ее можно сбросить без высвобождения и переиспользовать.

Arena arena = New!Arena(10 * 1024); // первый блок в 10 Кб

// Записываем строку в арену:
string s1 = arena.store("This string is now stored in arena!");

// Можно создавать массивы, экземпляры классов и структур:
int[] arr = arena.create!(int[])(100);
Foo foo = arena.create!Foo(20);
MyStruct* stru = arena.create!MyStruct();

// Операции над строками - конкатенация, разбивка, объединение:
string s2 = arena.cat("Hello, ", "world", "!");
string[] parts = arena.split("one,two,three,four", ',');
string joined = arena.join(parts, " + ");

// Форматирование строк:
string s3 = arena.format("The answer is %d", 42);

// Замена:
string r = arena.replace("the quick brown fox jumps over the lazy dog", "the", "a");

Arena — это owned-объект, она может иметь владельца и автоматически удаляться, когда удаляется владелец. Это делает ее удобным хранилищем для временных данных, которые существуют лишь в рамках какого-то алгоритма и не нужны в общем контексте приложения (например, можно хранить в арене приватные данные worker-потока). А еще ее можно использовать как быструю фрейм-локальную память, очищаемую в конце каждого шага игрового цикла.

Вычислительные шейдеры в Dagon

С переходом на OpenGL 4.3 в движке появляется возможность использовать вычислительные шейдеры. Они довольно удобно легли поверх уже существующией шейдерной системы — добавились всего два новых класса, ComputeProgram (аналог ShaderProgram) и ComputeShader, наследующий от Shader. Механизм привязки параметров целиком остается тот же самый, никаких изменений, за исключением способа привязки текстур — для этого есть метод ComputeShader.bindImageTexture. Привязываются обычные текстуры Dagon, как-то специально их готовить не нужно. Чтобы создать свой шейдер, нужно наследовать от ComputeShader, создать программу и параметры, а в bindParameters, как обычно, задать им значения:

class TestComputeShader: ComputeShader
{
   protected:
    String cs;
    ShaderParameter!Color4f _fillColor;

   public:
    Texture outputTexture;
    Color4f fillColor = Color4f(1.0f, 1.0f, 1.0f, 1.0f);

    this(Owner owner)
    {
        cs = Shader.load("data/test.comp.glsl");
        ComputeProgram p = New!ComputeProgram(cs, this);
        super(p, owner);

        _fillColor = createParameter!Color4f("fillColor");
    }

    ~this()
    {
        cs.free();
    }

    override void bindParameters(GraphicsState* state)
    {
        _fillColor = fillColor;

        if (outputTexture)
            bindImageTexture(0, outputTexture, TextureAccessMode.Write);

        super.bindParameters(state);
    }

    void run()
    {
        if (outputTexture)
            super.run(outputTexture.width, outputTexture.height);
    }
}

TestComputeShader cs = New!TestComputeShader(assetManager);
cs.outputTexture = myTexture;
cs.fillColor = Color4f(0.0f, 0.5f, 1.0f, 1.0f);
cs.run();

Пример шейдера, который заполняет текстуру «синусной плазмой» заданного цвета:

#version 430
layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;

layout(rgba8, binding = 0) writeonly uniform image2D outputTexture;

uniform vec4 fillColor;

void main()
{
    ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
    const float scale = 0.25;
    float value = 0.5f + 
        0.25f * cos(coord.x * scale) + 
        0.25f * cos(coord.y * scale);
    vec3 color = fillColor.rgb * value;
    imageStore(outputTexture, coord, vec4(color, 1.0));
}

ComputeShader.run уже ставит барьер памяти, в приложении это делать не нужно. Есть методы dispatch и barrier для запуска вручную.

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), где геометрия проецируется на две текстуры глубины, с двух сторон относительно источника света.