LDC поддерживает SPIR-V!

Не секрет, что компилятор LDC, благодаря бэкенду на LLVM, поддерживает множество разных целевых платформ – и в их числе, оказывается, есть SPIR-V. Мне удалось собрать вот такое минимальное вычислительное ядро (которое, правда, ничего не делает):

module main;

import ldc.attributes;

@callingConvention("spir_kernel")
@llvmAttr("hlsl.shader", "compute")
extern(C) void compute_main() nothrow @nogc
{
    //
}
ldc2 -betterC -vgc -c --output-o -of=main.spv --mtriple=spirv-unknown-vulkan -mattr=+spirv1.0 source/main.d

Если вывести ассемблерный листинг, получается следующее:

OpCapability Shader
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %3 "compute_main"
OpSource Unknown 0
OpName %3 "compute_main"
%1 = OpTypeVoid
%2 = OpTypeFunction %1
%3 = OpFunction %1 None %2
%4 = OpLabel
OpReturn
OpFunctionEnd

Как видно, можно использовать этот SPIR-V модуль как вычислительный шейдер в Vulkan и OpenGL 4.6. Поддержки других видов шейдеров пока нет, но кто знает – может быть в один прекрасный день получится использовать D в качестве полноценного шейдерного языка! Я лично в это верю.

std140: статическая валидация структур

std140 – самый платформонезависимый лейаут uniform-блоков. Если вы передаете параметры в шейдер структурами, а не по одному, то лучше использовать именно его. Конечно, у переносимости есть своя цена: стандарт предусматривает несколько ограничений, самое известное из которых – выравнивание по 16 байтам. То есть, все поля структуры должны быть по размеру кратны 16 байтам. Например, если это вектор – то vec4, если матрица – то mat4. Некратные 16 байтам типы (float, int и др.) также можно использовать, но их необходимо выравнивать вручную – то есть, одиночный float пойдет все равно как vec4. Использование невыровненных данных приводит к тому, что в шейдере считываются неправильные значения, и это вызывает недоумение у начинающих.

Благодаря CTFE и статической интроспекции в D можно проверять структуры на соответствие этому правилу на этапе компиляции – все необходимое есть в std.traits:

import std.traits;

bool fieldsAligned(T, alias numBytes)()
{
    static if (is(T == struct))
    {
        alias FieldTypes = Fields!T;
        
        static foreach(FT; FieldTypes)
        {
            static if (FT.sizeof % numBytes != 0)
                return false;
        }
        
        return true;
    }
    else return false;
}

alias Std140Compliant(T) = fieldsAligned!(T, 16);

Теперь можно делать так:

import dlib.math.vector;
import dlib.math.matrix;

struct Uniforms
{
    Matrix4x4f modelViewMatrix;
    Matrix4x4f normalMatrix;
    Vector4f worldPosition;
    float someParameter; // Will not compile, use Vector4f instead
}

static assert(Std140Compliant!Uniforms,
    Uniforms.stringof ~ " does not conform to std140 layout");

Возникает вопрос, почему бы просто не делать align(16)? Можно!

struct AlignedUniforms
{
  align(16):
    float a;
    Vector3f b;
    Matrix4x4f c;
}

Преимуществом такого подхода является право использовать любые типы для полей, но это неэффктивно: например, вместо того, чтобы впустую расходовать три байта после a, можно было бы упаковать a вместе с b в один 16-байтный вектор. Поэтому я лично не фанат автоматического компиляторного выравнивания – лучше это делать вручную, защитившись от ошибок необходимыми статическими проверками.

Slang – универсальный шейдерный язык

Что выглядит читабельнее? Это:

struct VertexOutput
{
    @builtin(position) position: vec4<f32>,
    @location(0) fragmentPosition: vec4<f32>
};

@vertex
fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput
{
    let x = f32(i32(vertexIndex) - 1);
    let y = f32(i32(vertexIndex & 1u) * 2 - 1);
    var output: VertexOutput;
    output.position = vec4<f32>(x * 0.5, y * 0.5, 0.0, 1.0);
    output.fragmentPosition = 0.5 * (vec4<f32>(x, y, 0.0, 1.0) + 1.0);
    return output;
}

…или это:

struct VertexOutput
{
    float4 position: SV_Position;
    float4 fragmentPosition;
};

[shader("vertex")]
VertexOutput vertexMain(uint vertexIndex: SV_VertexID)
{
    let x = float(int(vertexIndex) - 1);
    let y = float(int(vertexIndex & 1u) * 2 - 1);
    VertexOutput output;
    output.position = float4(x * 0.5, y * 0.5, 0.0, 1.0);
    output.fragmentPosition = 0.5 * (float4(x, y, 0.0, 1.0) + 1.0);
    return output;
}

Второй листинг, на мой взгляд, выигрывает сравнение. Это код на Slang, HLSL-подобном языке, который позиционируется в качестве платформонезависимой основы для написания одних и тех же шейдеров под любые графические API, включая D3D12, Vulkan, Metal, OpenGL, а с недавних пор и WebGPU. Идея, конечно, не новая – если вы писали шейдеры в нулевые, то, наверное, помните Cg от NVIDIA – но с со времен попыток написать универсальный компилятор в низкоуровневые шейдерные языки утекло немало воды и сменилось несколько поколений графического железа. Сейчас, в эпоху Vulkan и SPIR-V, кросс-компиляция шейдеров актуальна как никогда: старичок GLSL уже сдает позиции, для новых стандартов создаются новые языки – индустрия переживает очередной этап фрагментации.

Я уже писал о своих впечатлениях от WGSL, встроенного шейдерного языка WebGPU, и многие его конструкции мне до сих кажутся спорными и неудобными. Особенно бесят типы вида vec4<f32>. Не так давно поддержка WGSL была добавлена в компилятор Slang, в связи с чем я теперь всерьез рассматриваю этот язык как основной для создания графического движка на WebGPU.

Главная киллер-фича Slang – это, конечно, модули. Причем, что интересно, есть и препроцессор а ля C с директивами #include, #ifdef и др. От HLSL и GLSL язык выгодно отличается большим набором фич, присущих современным высокоуровневым языкам: поддержкой вывода типов, пространств имен, функций-членов структур (с неизменяемым по умолчанию неявным this – по-моему, отличная идея!), а также конструкторов, геттеров/сеттеров и даже перегрузки операторов. Есть дженерики, интерфейсы, кортежи. Интересен тип Optional, который дополняет любой другой тип поддержкой значения none – чтобы можно было указать отсутствие какого-либо значения. Для SPIR-V и CUDA в языке есть ограниченная поддержка указателей. Очень полезный инструмент – декоратор ForceInline, который заменяет вызов функции подстановкой ее кода. Наконец, в языке есть автоматическое дифференцирование, которое используется в задачах машинного обучения.

Старую как мир проблему некоммутативности умножения матриц и векторов в Slang решили следующим образом: оператор * всегда означает произведение двух матриц, как принято в математике. Чтобы трансформировать вектор матрицей, нужно вместо m * v писать mul(v, m). Для такого старого ветерана OpenGL, как я, слегка непривычно, но жить можно 🙂

Репозиторий

Документация по языку

Первое знакомство с WGSL

У тех, кто работает с низкоуровневой графикой, сегодня на слуху WebGPU – новый кроссплатформенный API для доступа к возможностям современных видеокарт. WebGPU призван объединить Vulkan, Metal и D3D12 под унифицированным набором функций и станет не просто веб-стандартом, но и, в перспективе, неплохой заменой OpenGL: реализации этого API уже существуют в виде рабочих прототипов wgpu-native от Mozilla и Dawn от Google – любой может использовать их в своих собственных приложениях.

WebGPU имеет сравнительно простую архитектуру, доступную для понимания “простыми смертными” практически с первого прочтения заголовочного файла. Единственной проблемой до недавнего времени было отсутствие консенсуса по шейдерному языку – существующие реализации WebGPU использовали двоичное промежуточное представление SPIR-V от Khronos, а Apple настаивала на текстовом языке на основе WSL. Компромиссом стал WGSL (WebGPU Shading Language), высокоуровневый язык со строго определенной семантикой и буквальной трансляцией в/из SPIR-V. Многие разработчики оказались недовольны, так как SPIR-V уже успел стать привычным решением и оброс инструментами – сегодня можно компилировать в SPIR-V код на всех языках предыдущих поколений. Однако я вижу больше преимуществ, чем недостатков – перечислю некоторые из них.

  • Использование SPIR-V усложняет жизнь при создании игрового движка, требует внедрения дополнительной стадии компиляции шейдеров на стороне разработчика. Референсным компилятором шейдеров считатеся GLSLang от Khronos, но его довольно трудно встроить в приложение как библиотеку, особенно если вы не пишете на C++ – приходится использовать GLSLang как приложение, и это усложняет тулчейн разработки, если нужна кроссплатформенность. Встроенный в API высокоуровневый язык решает эту проблему.
  • WGSL разрабатывается как текстовый аналог SPIR-V – они имеют общий набор возможностей. Это значит, что не будет повторения ситуации с GLSL, когда язык по-разному обрабатывается в компиляторах от различных поставщиков. Сохраняется главное преимущество SPIR-V при высоком удобстве использования.
  • Vulkan-диалект GLSL 4.60, являющийся де-факто стандартным языком под SPIR-V, имеет множество костылей и архаизмов – у WGSL более продуманный синтаксис, лишенный неявности и многозначности.

Синтаксис WGSL имеет много общего с Rust, особенно заголовки функций:

fn someFunc(x: i32) -> i32 {
    //...
}

Типы объявляются через двоеточие после идентификатора, переменные – при помощи ключевого слова let, константность подразумевается по умолчанию. Для изменяемых переменных есть ключевое слово var. Система типов также пришла из вселенной Rust. Векторные типы имеют форму vec4<f32> (вместо простого vec4), что позволяет явным образом указать битовость используемых чисел. При этом можно объявить type vec4f = vec4<f32>; и писать коротко, если вам так привычнее.

Кстати, очень порадовало, что есть вывод типов – можно не указывать тип переменной, если она тут же инициализируется:

let a = vec4<f32>(0.0, 0.0, 0.0, 1.0);

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

[[location(0)]] position: vec4<f32>;

Встроенные переменные конвейера обозначаются атрибутом builtin, что весьма удобно при объявлении структур для хранения промежуточных результатов:

struct VertexOutput
{
    [[builtin(position)]] position: vec4<f32>;
    [[location(0)]] color: vec4<f32>;
};

Сравните это с GLSL, где для встроенных переменных используется зарезервированный префикс gl_.

Структуры, являющиеся uniform-блоками, помечаются атрибутом block:

[[block]] struct Uniforms {
    //...
};

Прямым аналогом вулкановских set и binding являются атрибуты group и binding.

Vulkan/GLSL:

layout(set=0, binding=0) uniform Uniforms uniforms;

WGSL:

[[group(0), binding(0)]] var uniforms: Uniforms;

Программы на WGSL можно не разделять на два текста – вершинный и фрагментный шейдеры можно хранить в одном файле и, таким образом, использовать общие объявления. Для этого используется атрибут stage. Названия самих входных точек могут быть произвольными, но чаще всего в примерах используют vs_main и fs_main.

[[stage(vertex)]]
fn vs_main() -> VertexOutput
{
    //...
}

[[stage(fragment)]]
fn fs_main(input: FragmentInput) -> [[location(0)]] vec4<f32>
{
    //...
}

Очень непривычно в WGSL записываются циклы:

var i = i32(0);
loop {
    break if (i == 5);
    //...
    continuing {
        i = i + 1;
    }
}

Впрочем, на момент написания статьи обсуждается возможность поддержки классического for.

Подведу итог: с первого взгляда WGSL кажется хорошим решением давней проблемы с языками шейдеров. Высокоуровневое представление SPIR-V – это отличная идея. Непривычный синтаксис и конструкции со спорным юзабилити могут усложнить портирование на WGSL готовых шейдеров, но в целом впечатление от языка весьма позитивное.

Шейдер ландшафта и декали

Шейдер ландшафта и декали

Наконец-то добавил в Dagon специализированный материал для объекта Terrain – 4-канальный шейдер ландшафта, поддерживающий карты нормалей.

До этого также появилась поддержка отложенного рендеринга декалей (deferred decals) – текстур, проецируемых на статические объекты. При помощи декалей можно сделать на поверхностях различные следы, пятна, надписи, граффити, мелкий мусор и т.д. Для декалей поддерживаются карты нормалей, PBR и излучения света, так что они позволяют разнообразить сцену с высокой степенью реалистичности. Реализованы они путем блиттинга текстур в G-буфер – таким образом, декали могут быть отрисованы поверх уже отрендеренной геометрии с возможность смешивания цвета, нормалей и других атрибутов поверхности по альфа-маске. В демо-приложении Dagon декали используются для рендеринга следов игрока на земле: