Нововведения в GScript3

Давненько не писал о GScript! Между тем, VM языка уже пригодна для использования в играх, а спецификация пополнилась множеством интересных фич, о которых я сейчас и расскажу.

Изоляция памяти потоков

О потоках в GScript3 я уже писал в отдельном посте. Разрабатывая модель памяти языка, я придумал совершенно новую парадигму, которую в существующих реализациях не видел. Каждый поток имеет свою изолированную арену для динамических аллокаций (с размером блока в 1024 байта). Ссылочные типы — объекты, массивы, динамически созданные строки — в контексте потока создаются в этой арене и по умолчанию помечаются как принадлежащие этому потоку (у каждой переменной GsDynamic есть поле владельца — owner). VM запрещает ссылке покидать изолированный контекст, проверяя владельца при операциях внешней передачи — например, при записи в глобальную переменную. Исключение делается только для главного потока, который имеет право писать куда угодно без ограничений.

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

const thread = spawn func
{
    const arr1 = shared [10, 5, 6];
    const arr2 = [10, 5, 6];

    global.arr = arr1; // ok
    global.arr = arr2; // error!
};

shared, строго говоря, применяется не к данным как таковым, а к операции конструирования. Например, его можно применить к конкатенации строк, чтобы результат был разделяемым:

const s = shared("a" ~ "b");

Помимо этого, есть ключевое слово escape. Оно, как и shared, применяется к результату выражения. Оно берет неразделяемые данные и делает их разделяемыми — то есть, якобы принадлежащими основному потоку. Это позволяет передать ссылку во внешний контекст, даже если она указывает на изолированную арену.

func test(value)
{
    print value;
}

const thread = spawn func
{
    const arr = [0, 1, 2];
    test(escape arr);
};

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

Каналы

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

const ch = global.channel();

const thread1 = spawn func
{
    print ch.receive();
    ch.send("world");
};

const thread2 = spawn func
{
    ch.send("hello");
    print ch.receive();
};

Приведенная программа выведет строку «hello», затем строку «world». Порядок гарантируется, потому что сначала будет запущен первый поток, который ждет сообщения «hello» от второго, а затем отправляет ему «world».

Проверка типов и обработка ошибок

Тип динамического значения можно сравнить с константой типа с помощью оператора :

const x = 10;
if (x: Number)
{
    // ...
}

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

if (type(x) != Null)
{
    // ...
}

if (type(x) == type(y))
{
    // ...
}

Константы типов в языке следующие:

Null = 0
Number = 1
String = 2
Array = 3
Object = 4
NativeMethod = 5
NativeFunction = 6
Error = 7
Function = 8
Vector = 9

Вы, наверное, заметили, что отдельной константой типа является Error — «ошибка». Ошибка в GScript3 действительно является отдельным типом переменной. Это позволяет не использовать пресловутый Null для обработки ошибок (хотя отказ от Null вообще — фича крайне спорная и даже безумная, я этого не стал делать) и одновременно — реализовать очень простую, не перегружающую синтаксис обработку ошибок на уровне VM.

Простейший случай — функция просто возвращает ошибку:

func myFunc()
{
    return error("BADBEAF");
}

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

const thread = spawn myFunc;

let running = true;
while(running)
{
    running = thread.running;
    const result = await thread;
    if (result: Error)
    {
        print result;
    }
}

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

func test()
{
    raise error("Something");
}

func threadFunc()
{
    test();
}

const thread = spawn threadFunc;

let running = true;
while(running)
{
    running = thread.running;
    const result = await thread;
    if (result: Error)
    {
        print result;
    }
}

Векторы

Свежее нововведение. Поскольку GScript3 создан с прицелом на игры, в языке не помешает векторный тип данных, и теперь он есть. Поддерживаются арифметические операции и встроенные функции (интринсики) для ускорения вычислений:

const v = vector(3, 2, 1) + vector(1, 2, 3);
print v;
print normalize(v);

Вектор является стековой структурой и хранится в стандартном GsDynamic.

VM реализует следующие интринсики для векторов: dot, cross, normalize, distance.

В этом посте я не затронул еще две важные темы — AST-макросы и библиотеки байт-кода. Они заслуживают отдельных статей.

Формат DAF

В предыдущем посте я уже писал о том, почему glTF нельзя использовать в качестве полноценного формата игровых ассетов, и вот альтернатива, которую я специально проектирую для Dagon 2.0 — Dagon Asset Format, сокращенно DAF.

DAF хранит, в первую очередь, вершинные буферы для прямой передачи в VRAM. Формат бинарный, архитектурно соответствующий API Dagon 2.0 и расширяемый: в нем могут быть объявлены любые дополнительные структуры данных и даже динамические свойства, не ломая обратную совместимость.

Основные задачи формата:

  • Хранение данных в форме, подходящей для прямой загрузки в видеопамять без накладных расходов на обработку (zero overhead). В отличие от glTF, в DAF вершинные буферы имеют фиксированный формат, согласованный с пайплайном движка, не требуя конверсии;
  • Максимальная эффективность десериализации. glTF требует парсинга JSON и динамического построения довольно сложных объектов в памяти (списков, словарей), а загрузка DAF – это просто реинтерпретация слайсов байтового буфера в массивы POD-структур. DAF экономит память и сокращает риск утечек, поскольку не требует множества аллокаций;
  • Частичная десериализация. Декодер может читать из DAF только те данные, которые ему нужны, не разбирая остальные.
  • Формат «все в одном». Файл DAF может хранить как отдельный меш, так и целую сцену. Все структуры формата поддерживают пользовательские свойства, что позволяет хранить в DAF метаданные редактора. Фактически, DAF может быть использован как простая NoSQL база данных для различных целей.
  • Поддержка семантики данных. Все объекты имеют список классов, что позволяет движку группировать их для задач игровой логики. Все текстуры помечаются как baseColor, normal, height, roughness-metallic, emission, для того, чтобы движок мог выбрать оптимальный BCn формат сжатия.
  • Поддержка данных для физики и проверки столкновений (в разработке).

Спецификация DAF находится здесь.

Недостатки формата glTF

В Dagon 1.0 я очень много времени потратил на поддержку этого монструозного формата моделей, и спустя пять лет работы с ним у меня не осталось ничего, кроме разочарования. Главный вывод: glTF — не для игровых ассетов (т.е. карт и отдельных переиспользуемых моделей). Это формат, предназначенный исключительно для просмотра, причем в основном в браузере, на WebGL, при помощи вьюверов типа Sketchfab. В этом он свою задачу выполняет, хотя и тоже неидеально. Но то, что его все начали использовать как формат обмена данными между программами моделирования и игровыми движками — это фундаментальная ошибка, которую я и сам по наивности допустил. Ниже мой личный список недостатков glTF, актуальных, прежде всего, в контексте игрового рендеринга.

  • Трансформация нод в glTF хранится либо в виде комбинации позиции, поворота и масштаба (TRS), либо в виде матрицы 4×4. Такая вариативность сильно усложняет игровую логику, так как без декомпозиции матрицы обратно в TRS вы не можете ничего анимировать. Декомпозиция — задача нетривиальная, у нее нет стопроцентно надежных решений, и это делает glTF-сцены в общем случае неподходящими для интерактивных приложений. Намного оптимальнее всегда хранить трансформацию в виде TRS и конвертировать в матрицу в движке, что является простейшей задачей.
  • Нефиксированный лейаут вершин — то есть, физическое расположение атрибутов в общем массиве данных может вариьироваться от одного меша к другому, равно как и формат чисел в буферах. Это совместимо с OpenGL/WebGL, но очень плохо для низкоуровневых API, где формат вершин фиксирован на уровне PSO и не может меняться в проходе. Движок вынужден конвертировать буфер в свое внутреннее представление, что убивает саму идею формата — прямую загрузку данных в видеопамять без препроцессинга.
  • Поддержка моделей без некоторых атрибутов — например, без текстурных координат или нормалей. В игровом движке не может быть такого, что нормали отсутствуют, для PBR-пайплайна их все равно нужно предоставить шейдеру. Поэтому отсутствующие атрибуты приходится генерировать — а как это делать, glTF не регламентирует, приходится изобретать свою логику. Это, опять-таки, противоречит идее «эффективного GPU-формата».
  • Кости для скелетной анимации определены как обычные ноды сцены, хотя они логически принадлежат не сцене, а отдельному виду объектов — собственно скелету. Скелет не вставляется в граф сцены, а используется как источник данных для построения pose-матриц для каждой индивидуально анимированной модели в сцене. То есть, с точки зрения архитектуры движка, glTF-подход к скелетке очень далек от эффективного решения несложной в принципе задачи.
  • Поддержка отрицательного масштаба. Для рендеринга это зло, потому что меняет winding и влияет на отбор видимости. Иногда при рендеринге это используется целенаправленно, но в форматах 3D-моделей практически всегда приводит к проблемам совместимости.
  • Использование web-first форматов текстур — PNG и JPEG. В AAA-движках эти форматы практически не используются из-за того, что они требуют декодирования на стороне CPU. Кроме того, для игр с большими мирами жизненно важно блочное сжатие текстур, это индустриальный стандарт. А с glTF получается, что нужен отдельный этап декодирования и сжатия изображений в GPU-френдли форматы. Это может и не быть большой проблемой, если движок поддерживает такой процесс, но все равно возникает много сложностей. Основной вопрос — в какой формат сжимать? Если это base color, то, конечно, достаточно BC1/BC3 (в зависимости от наличия альфа-канала), но для карт нормалей уже желателен BC7, а для черно-белых изображений эффективнее BC4. glTF никак не регламентирует семантику текстур, она определяется на уровне материалов, что в общем случае не позволяет реализовать полностью автоматический алгоритм выбора формата.

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

Прогресс по Dagon 2

За последние недели значительно продвинулся с портом: реализовал в Dagon 2 загрузку glTF (через библиотеку Assimp), обновил шейдер антиалиасинга, добавил цветокоррекцию и поддержку 3D-текстур, проделал множество мелких улучшений в рендере.

Модель — Datsun 280Z от Martin Trafas (бесплатная).

Новые скриншоты Dagon 2

Еще несколько тестов SSLR с различными поверхностями пола и картами окружения:

Также я завел отдельную страничку, посвященную Dagon 2.

SSLR и темпоральная аккумуляция

Как и планировал, сумел победить зашумленность отражений при помощи накопления данных с предыдущих кадров (с репроекцией по motion-векторам для компенсации медленного движения камеры):

Это решение также неплохо сглаживает шум, вызванный неоднородностью нормалей:

При резких движениях, конечно, репроекция не спасает, приходится сбрасывать историю, и шум возвращается. И, конечно, экранная трассировка — это аппроксимация, у нее всегда будут видимые артефакты в виде пустот там, где сэмплинг невозможен ввиду недостатка информации в буферах. Но результат вполне, как говорится, shippable — мне кажется, у меня получилось даже лучше, чем во многих коммерческих играх. Во многом, это благодаря использованию GGX importance sampling: распределение лучей очень близко к тому, что делается для префильтрации кубических карт под PBR.

SSLR в Dagon 2.0

Реализацию этого эффекта я в свое время отложил до выхода первой версии движка, и вот, наконец, пришло время за нее взяться. SSLR (screen-space local reflections) — это расчет отражений в пространстве экрана, упрощенная разновидность трассировки лучей. Берется HDR-буфер, буферы глубины и нормалей и в каждой точке выстреливается луч в направлении зеркального отражения поверхности. Луч делится на сегменты, и каждый сегмент последовательно проверяется на попадание в геометрию (путем сравнения Z со значением из буфера глубины). Если попали, то читаем цвет, соответствующий по экранным UV-координатам точке попадания.

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

Новости по Dagon 2

Разработка Dagon 2.0 началась в весьма бодром темпе. Deferred-рендер уже почти готов — остались только тени PSM и DPSM, поддержка локальных зондов освещения и forward-проход. Я добавил систему кэширования ресурсов, сжатие в BC7 (на основе компрессора bc7enc Рича Гелдриха) и проделал еще множество мелких улучшений на разных стадиях формирования кадра. Особое внимание я уделил тому, чтобы картинка соответствовала Eevee в Blender 5.

Что еще нового? Часть функциональности, которая в Dagon 1.0 реализована в качестве расширений, теперь входит в ядро — это физика на базе Jolt и загрузчик текстур в формате KTX/KTX2. Такое решение я принял исходя из полезности этих фич, простоты сборки Jolt и libktx из исходников и их автономности: они не имеют собственных зависимостей и отлично работают на всех платформах (в противовес тому же Newton, который имеет проблемы с работой некоторых функций под Linux). Наличие libktx «из коробки» дает серьезные преимущества и ставит Dagon 2 в авангард движкостроения; в будущем не исключен перевод текстурного кэша с DDS на KTX2.

Еще одним нововведением будет встроенная VM GScript3, которую я разработал в прошлом году. Движок при старте загружает скомпилированный байт-код скрипта и выполняет его, а скрипт, в свою очередь, навешивает обработчики событий, позволяя, таким образом, выполнять внешнюю логику без пересборки игры. Игра может экспонировать скриптовой системе свои данные и методы, что полезно для создания модов. Некоторые встроенные классы Dagon уже реализуют интерфейс GsObject и напрямую совместимы с GScript: это Entity, Scene, World, BaseGame.

Зарегистрирован пакет dagon2 в реестре DUB, так что начать пользоваться можно уже сейчас, несмотря на то, что разработка находится на ранней стадии.

Переезд сайтов на домене ru

В связи с тем, что в России для владельцев доменов в зонах ru/su/рф ввели обязательное подтверждение личности через Госуслуги, я решил отказаться от timurgafarov.ru и перенести все свои персональные ресурсы на домен pixperfect.online. Это касается следующих сайтов и страниц:

Также https://xtreme3d.ru переезжает на https://xtreme3d.org.

Редиректы со старых URL будут работать до сентября 2026 года.

Dagon 1.0

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

Исправлен баг с неправильным сэмплингом BRDF LUT, что ранее приводило к излишней яркости шероховатых поверхностей. Исправлен эффект тумана для точечных источников света.
Улучшена система частиц. Добавлено свойтсво Emitter.fadeInDuration для плавного появления частиц из прозрачности, а также Emitter.gravity для управления гравитацией частиц. Свойство Emitter.initialPositionRandomRadius теперь является вектором и называется Emitter.initialPositionRandomRadii — это радиус-вектор, позволяющий задать разброс случайного появления частиц отдельно по всем трем осям.
Добавлен новый тип событий CustomResize, который используется в тех случаях, когда пользовательский код должен обрабатывать кастомный ресайз вьюпорта (обычно это необходимо, если размер вьюпорта не привязан жестко к размеру окна и вычисляется другим способом). Также менеджер событий теперь обрабатывает SDL_WINDOWEVENT_CLOSE для грациозного закрытия игры при нажатии Alt+F4. Добавлен новый метод Application.isWindowMinimized.
Добавлен новый режим воспроизведения анимации для GLTFBlendedPosePlayMode.OnceAndStop. Он позволяет проиграть анимацию один раз и остановиться на последнем ключевом кадре. Добавлено новое свойство GLTFBlendedPose.timeScale для управления скоростью анимации.
Добавлено свойство Light.angularRadius, которое симулирует солнечный диск угловым радиусом распределения света.
Улучшен шейдер ночного неба StarfieldSkyShader: добавлены свойства sunEnergy (энергия светила), spaceColorZenith (цвет пространства в зените), spaceColorHorizon (цвет пространства на горизонте).
Добавлено новое свойство FirstPersonViewComponent.roll — поворот камеры от первого лица по оси Z для эффекта наклона головы вбок.
В расширении dagon:jolt добавлена поддержка convex cast (JoltPhysicsWorld.shapeCast). Контроллер персонажа JoltCharacterController теперь использует shapeCast в логике приседания (для проверки высоты потолка над головой). Как следствие, персонаж теперь автоматически приседает, чтобы избежать врезания головой в низкие потолки. Добавлен метод onGround, который возвращает true, если персонаж стоит на земле.
Исправлена компиляция расширения dagon:nuklear.