Spiiin's blog

dasSDL3

Сделал привязку SDL к языку daslang.

SDL3#

Библиотека SDL абстрагирует доступ к железу и функциям операционной системы. В третьей версии появилась также абстракция над API современных GPU. При этом можно использовать SDL только для создания окна, а рисовать в нём с помощью других графических API — DirectX, Metal, OpenGL или Vulkan. Есть и несколько библиотек-сателлитов, которые расширяют возможности SDL: загрузка картинок, надстройки над аудио- и сетевыми API, простая 2D-графика.

Мне захотелось иметь такой швейцарский нож для daslang, поэтому я сделал с помощью ИИ привязку всего функционала SDL3 для нескольких платформ.

Автоматическое создание привязок#

Для автоматической генерации привязок используется dasClangBind, который работает с подмножеством C++. Он разбирает код библиотеки и создаёт базовые привязки. После этого требуется «доработка напильником»: исключить вспомогательные макросы и функции, не нужные в привязке, и обработать идиомы, которые плохо переносятся на другой язык — сырые указатели, объекты с разным временем жизни и разными способами управления памятью. Получить работающие привязки — первая и самая простая часть работы.

Идиоматичность#

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

При разработке интерфейса я ориентировался на привязку SDL3 для Rust. О принятых в сообществе Rust подходах к проектированию API я уже писал: Элегантные API библиотек на Rust. Здесь я адаптировал эти подходы под daslang.

Пайплайны#

Одна из фич, на которых строится удобный интерфейс — пайплайны. Pipelining might be my favorite programming language feature. В daslang для этого есть оператор |>: выражение value |> function(argument) эквивалентно function(value, argument). Результат одного вызова становится первым аргументом следующего, и код читается в порядке выполнения операций.

Builder#

Так можно постепенно собрать описание объекта. Например, задать размер окна, добавить флаг и выбрать положение:

options gen2
require dassdl3/sdl3_window_boost

var settings = (window_options("dasSDL3", int2(800, 600))
    |> window_flags(SDL_WINDOW_HIGH_PIXEL_DENSITY)
    |> window_position(int2(SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED)))

window_options создаёт WindowOptions, а каждый следующий вызов возвращает обновлённое описание. На этом этапе окно ещё не создано: параметры можно подготовить отдельно, а затем передать в with_window. Аналогичные цепочки есть для текстур, шейдеров, сэмплеров и графических пайплайнов.
Например, описание сэмплера с линейной фильтрацией, интерполяцией между mip-уровнями и повторением текстуры:

require dassdl3/sdl3_gpu_sampler_boost

var sampler_settings = (gpu_sampler()
    |> filters(SDL_GPUFilter.FILTER_LINEAR)
    |> mipmap_mode(SDL_GPUSamplerMipmapMode.SAMPLERMIPMAPMODE_LINEAR)
    |> address_modes(SDL_GPUSamplerAddressMode.SAMPLERADDRESSMODE_REPEAT))

filters задаёт фильтрацию при уменьшении и увеличении текстуры, mipmap_mode — при выборе между mip-уровнями, а address_modes — поведение координат за пределами текстуры. Результат — обычный SDL_GPUSamplerCreateInfo, который можно передать в with_gpu_sampler(device, sampler_settings) для создания GPU-ресурса с заданным временем жизни.

Здесь функции остаются обычными свободными функциями. Чтобы получить цепочку вызовов, не нужно превращать описание в класс с методами. Скобки вокруг многострочного выражения нужны для продолжения строк с |>.

Именованная инициализация#

Для коротких описаний удобно сразу указать нужные поля:

var settings = WindowOptions(title = "dasSDL3", size = int2(800, 600))
var rectangle = SDL_FRect(x = 20.0, y = 30.0, w = 160.0, h = 90.0)

Builder удобен, когда описание собирается по шагам; именованная инициализация — когда все параметры известны сразу.

Возвращаемые значения и Result#

В C API результат часто записывается в параметры, переданные по указателю. В надстройке можно получить его как обычное значение. Например, window_size(window) возвращает размер окна в Result<int2, SdlError>: либо размер, либо описание ошибки. В синтаксисе daslang этот тип записывается как $Result<int2; SdlError>.

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

typedef public SdlStatus = $Result<SdlUnit; SdlError>

Теперь вместо полного типа можно писать SdlStatus в сигнатурах функций. SdlUnit играет роль пустого значения успешного результата, а sdl_ok() создаёт такой результат. В SdlError сохраняются имя операции и текст ошибки, скопированный до освобождения ресурсов.

Можно вводить и сокращения для конкретной задачи:

typedef WindowSizeResult = $Result<int2; SdlError>

Для результатов с разными типами значений в библиотеке есть обобщённая форма $SdlResult<T>: например, $SdlResult<int2> означает тот же $Result<int2; SdlError>. Она реализована макросом типов, который подставляет SdlError в стандартный Result. Эти сокращения дают типам удобные имена, сохраняя их представление и поведение.

Для отсутствующего значения используется Option. Например, настройка SDL может быть не задана, и тогда можно выбрать запасное значение:

require dassdl3/sdl3_init_boost

let driver = hint_string("SDL_RENDER_DRIVER") |> unwrap_or("automatic")

Так интерфейс различает ошибку операции и нормальное отсутствие результата.

Ранний возврат с sdl_try#

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

require dassdl3/sdl3_window_boost

def print_size_and_clear(window : SDL_Window?; renderer : SDL_Renderer?) : SdlStatus {
    let size_result = window |> window_size()
    if (is_err(size_result)) {
        return err(unwrap_err(size_result), type<SdlUnit>)
    }
    let size = unwrap(size_result)
    print("{size.x} x {size.y}\n")

    var clear_result = renderer |> clear()
    if (is_err(clear_result)) {
        return <- clear_result
    }
    return sdl_ok()
}

window_size и вся функция возвращают результаты с разными типами успешных значений: int2 и SdlUnit. Поэтому при ошибке первого вызова нужно перенести SdlError в результат с подходящим типом. Для clear можно вернуть полученный результат целиком.

С sdl_try та же функция становится короче:

require dassdl3/sdl3_window_boost
require dassdl3/sdl3_try

def print_size_and_clear(window : SDL_Window?; renderer : SDL_Renderer?) : SdlStatus {
    let size = window |> window_size() |> sdl_try
    print("{size.x} x {size.y}\n")
    renderer |> clear() |> sdl_try
    return sdl_ok()
}

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

Окружающая функция или блок должны возвращать Result с ошибкой типа SdlError. Сам sdl_try не извлекает значение из Option и не управляет временем жизни указателей; для ресурсов используются области with_*.

Та же идея в других языках#

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

Ниже — псевдокод SDL API: сначала создаём окно, затем renderer для него. Функции создания возвращают Option/Maybe; если любой шаг не дал значения, дальнейшие шаги пропускаются.

Rust: оператор ? извлекает значение из Some, а при None возвращает None из текущей функции. Точки возможного выхода видны прямо в выражениях:

fn create_window_and_renderer() -> Option<(Window, Renderer)> {
    let window = create_window("SDL", 800, 600)?; // Выход с None, если окно не создано.
    let renderer = create_renderer(&window)?;    // Выход с None, если renderer не создан.
    Some((window, renderer))
}

Haskell: в do-блоке для Maybe оператор <- извлекает значение из Just. При Nothing весь блок даёт Nothing, а продолжение не выполняется. Это поведение задаёт связывание вычислений для Maybe: место прекращения цепочки оказывается «между строк», без отдельного оператора выхода.

createWindowAndRenderer :: Maybe (Window, Renderer)
createWindowAndRenderer = do -- Вставляет логику «между строк».
    window <- createWindow "SDL" 800 600
    -- При Nothing дальнейшие шаги пропускаются; результат блока — Nothing.
    renderer <- createRenderer window
    -- При Nothing дальнейшие шаги пропускаются; результат блока — Nothing.
    pure (window, renderer)

Время жизни ресурсов через блоки#

В C++ для управления ресурсами привычен RAII: объект-владелец получает ресурс при создании и освобождает его в деструкторе при выходе из области видимости. Для daslang такая модель с объектами-владельцами нехарактерна: время жизни внешних ресурсов удобно задавать явными блоками и отложенной очисткой через defer. В языке есть финализаторы и inscope, но сам по себе указатель на объект SDL не задаёт правила владения и освобождения.

Создание окна или текстуры — только половина задачи: ресурс нужно освободить, в том числе при раннем возврате из-за ошибки. Для этого есть функции with_*, которые передают ресурс в блок и освобождают его после завершения блока. sdl_scope и sdl_use позволяют записать несколько таких вложенных блоков линейно:

def draw_frame() : SdlStatus {
    return sdl_scope() {
        with_sdl(SDL_INIT_VIDEO) |> sdl_use
        let window : SDL_Window? = with_window(
            WindowOptions(title = "dasSDL3", size = int2(800, 600))) |> sdl_use
        let renderer : SDL_Renderer? = window |> with_renderer() |> sdl_use

        renderer |> clear() |> sdl_try
        renderer |> set_color(uint4(70u, 200u, 160u, 255u)) |> sdl_try
        renderer |> fill_rect(SDL_FRect(x = 20.0, y = 30.0, w = 160.0, h = 90.0)) |> sdl_try
        renderer |> present() |> sdl_try
        return sdl_ok()
    }
}

Это пример одного кадра; для приложения внутри области жизни ресурсов нужен цикл обработки событий и отрисовки. После завершения блока сначала освобождается renderer, затем окно, затем завершается работа с SDL. Если создание renderer или отрисовка вернули ошибку, уже созданные ресурсы также освобождаются.

Макрос sdl_use переносит оставшуюся часть блока в callback соответствующей функции with_*. Полученные указатели остаются заимствованными: их можно использовать внутри этой области, но нельзя сохранять для дальнейшей работы или освобождать вручную. Это управление временем жизни через структуру программы; отдельный сборщик ресурсов здесь не нужен.

Тот же пример без with_* и sdl_use для сравнения (если еще sdl_try развернуть, получается почти по сишному). Каждый ресурс создаётся до входа в блок с его очисткой. Defer переносится в секцию завершения всего своего блока, поэтому размещать его после создания ресурса в том же блоке недостаточно — при раннем возврате до успешного создания очистка тоже могла бы выполниться.

def draw_frame_manual() : SdlStatus {
    sdl_init(SDL_INIT_VIDEO) |> sdl_try
    {
        defer() { SDL_Quit() }

        let window = create_window("dasSDL3", 800, 600) |> sdl_try
        {
            defer() { destroy_window(window) }

            let renderer = window |> create_renderer() |> sdl_try
            {
                //Hadouken!!!
                defer() { destroy_renderer(renderer) }
                renderer |> clear() |> sdl_try
                renderer |> set_color(uint4(70u, 200u, 160u, 255u)) |> sdl_try
                renderer |> fill_rect(SDL_FRect(x = 20.0, y = 30.0, w = 160.0, h = 90.0)) |> sdl_try 
                renderer |> present() |> sdl_try
                return sdl_ok()
            }
        }
    }
}

При ошибке создания renderer выполнится очистка окна и SDL. При ошибке отрисовки освободятся все три ресурса, в обратном порядке. with_* инкапсулируют эти блоки и правила освобождения, а sdl_use позволяет записать их использование без ручной вложенности.

События как последовательность#

Обработку очереди событий можно записать обычным циклом:

require dassdl3/sdl3_events

def close_requested(window : SDL_Window?) : bool {
    for (event in poll_events()) {
        if (should_close(event, window)) {
            return true
        }
    }
    return false
}

poll_events() — ленивый итератор: он извлекает по одному событию и заканчивает работу, когда очередь пуста. Если выйти из цикла раньше, следующие события останутся в очереди. Вместо сырого SDL_Event с C union код получает SdlEvent, вариантный тип с декодированными данными события. Строки и списки в этих данных принадлежат полученному значению, поэтому следующий вызов polling не перезапишет их.

Pattern matching событий#

Вариантный тип SdlEvent удобно разбирать через match. Каждая ветка получает данные соответствующего события:

require dassdl3/sdl3_events
require daslib/match

def print_events() {
    for (event in poll_events()) {
        match (event) {
            if (SdlEvent(key_down = $v(key))) {
                print("Key: {key.scancode}, repeat: {key.repeat}\n")
            }
            if (SdlEvent(mouse_motion = $v(mouse))) {
                print("Mouse: {mouse.position}\n")
            }
            if (SdlEvent(text_input = $v(input))) {
                print("Text: {input.text}\n")
            }
            if (_) { }
        }
    }
}

Заимствованный доступ к памяти#

Для работы с пикселями используется ещё одна форма той же идиомы с блоками: библиотека временно предоставляет доступ к памяти текстуры. Например, заполним потоковую текстуру формата RGBA32 размером 32 × 32 градиентом:

require dassdl3/sdl3_pixel_views

def paint_gradient(texture : SDL_Texture?) : SdlStatus {
    return with_texture_pixels_rgba8(texture, SDL_Rect(w = 32, h = 32)) $(pixels) {
        for (y in 0 .. pixels.height) {
            pixels |> with_row(y) $(var row : array<uint>#) {
                for (x in 0 .. length(row)) {
                    row[x] = rgba8(uint(x * 8), uint(y * 8), 180u, 255u)
                }
                return sdl_ok()
            } |> sdl_try
        }
        return sdl_ok()
    }
}

with_texture_pixels_rgba8 блокирует текстуру на время выполнения блока, а with_row даёт заимствованный массив пикселей одной строки. В примере # отмечает временный заимствованный доступ, сохранить или передать эти данные из блоки нельзя; rgba8 упаковывает компоненты в один uint. Код работает с памятью текстуры напрямую, а библиотека учитывает шаг между строками и снимает блокировку при завершении блока, в том числе при возврате ошибки. Также обёртка определяет тип данных, нет unsafe обращений через void*-указатели.

Разные типы для разных ресурсов#

Обычный typedef сокращает имя типа, но не отделяет его от исходного. Для проверяемого GPU API нужны именно разные типы: буфер, текстура и сэмплер не должны случайно подменять друг друга. Поэтому handles зарегистрированы как distinct-типы. Их определения эквивалентны следующим; повторно объявлять их в скрипте не нужно:

typedef distinct GpuBufferHandle = uint64
typedef distinct GpuTextureHandle = uint64
typedef distinct GpuSamplerHandle = uint64

Все три имеют одинаковое машинное представление, но для компилятора это разные типы. Например, функция привязки вершин принимает массив именно GpuBufferHandle:

require dassdl3/sdl3_gpu_recording_boost

def bind_vertices(device : SDL_GPUDevice?; pass1 : GpuRenderPassHandle;
                  buffer : GpuBufferHandle) : SdlStatus {
    var buffers <- array<GpuBufferHandle>(buffer)
    var offsets <- array<uint>(0u)
    return gpu_bind_vertex_buffers(device, pass1, 0u, buffers, offsets)
}

Передача GpuTextureHandle вместо buffer будет ошибкой компиляции. Во время выполнения проверяемый API дополнительно проверяет вид ресурса, его существование и принадлежность устройству. При этом копия handle остаётся ссылкой на тот же ресурс: отдельного владения или автоматического продления времени жизни она не создаёт. Эти проверки относятся к checked GPU API; прямые SDL-вызовы с нативными указателями сохраняют свои исходные контракты.

Массивы вместо указателей и размеров#

Многие функции C принимают указатель на данные и отдельный счётчик элементов. Для скрипта удобнее передавать массив: его размер уже известен. Например, нарисуем треугольник:

require dassdl3/sdl3_geometry_boost
require dassdl3/sdl3_try

def draw_triangle(renderer : SDL_Renderer?) : SdlStatus {
    var vertices <- array(
        vertex(float2(100.0, 20.0), float4(1.0, 0.0, 0.0, 1.0)),
        vertex(float2(180.0, 160.0), float4(0.0, 1.0, 0.0, 1.0)),
        vertex(float2(20.0, 160.0), float4(0.0, 0.0, 1.0, 1.0)))
    var indices <- array<int>(0, 1, 2)
    renderer |> draw_geometry(vertices, indices) |> sdl_try
    return sdl_ok()
}

Адаптер сам передаёт указатели и размеры в SDL. Перед вызовом он проверяет границы индексов, допустимые размеры массивов и значения вершин.

Временное изменение состояния#

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

require dassdl3/sdl3_pixels_boost
require dassdl3/sdl3_scope

def draw_to_texture(renderer : SDL_Renderer?) : SdlStatus {
    return sdl_scope() {
        let texture : SDL_Texture? = renderer |> with_target_texture(256, 256) |> sdl_use
        with_render_target(renderer, texture) {
            renderer |> clear(uint4(30u, 60u, 90u, 255u)) |> sdl_try
            return sdl_ok()
        } |> sdl_try

        // Предыдущая цель отрисовки уже восстановлена.
        renderer |> draw_texture(texture) |> sdl_try
        return sdl_ok()
    }
}

Частичный результат вместе со статусом#

Для IO одного Result<array<uint8>, SdlError> может быть недостаточно: операция могла передать часть данных, а затем завершиться ошибкой. Поэтому read_io и write_io возвращают IoTransfer, в котором число переданных байтов хранится отдельно от статуса:

require dassdl3/sdl3_iostream_boost
require dassdl3/sdl3_try

def read_chunk(stream : SDL_IOStream?) : SdlStatus {
    var bytes : array<uint8>
    resize(bytes, 4096)
    let transfer = stream |> read_io(bytes, 4096ul)
    print("Read: {transfer.transferred} bytes\n")
    let status = transfer.status |> sdl_try
    print("Status: {status}\n")
    return sdl_ok()
}

transferred доступен даже при ошибке в status.

Shader DSL#

Ещё одна возможность — писать шейдеры на daslang. Для этого используется уже существующий компилятор dasSpirv: аннотации отмечают shader-функции, а при компиляции скрипта он генерирует SPIR-V и метаданные reflection. SDL-слой использует эти результаты для создания GPU-ресурсов.

Получается такая цепочка: функция с аннотацией → SPIR-V и reflection → проверка раскладки ресурсов → создание SDL GPU shader. Шейдер компилируется при компиляции скрипта, а GPU-объект создаётся во время выполнения, когда уже есть устройство.

Описание шейдера#

Например, fragment shader, который берёт цвет из uniform-блока:

options gen2
require spirv/spirv_shader
require spirv/spirv_builtins

struct Tint { color : float4 }
var @uniform @set = 3 @binding = 0 tint : Tint
var @out @location = 0 output_color : float4

[fragment_shader(name = "solid_fragment")]
def fragment_main {
    output_color = tint.color
}

@uniform описывает данные, которые приложение передаёт шейдеру, а @out — его выход.

Аннотация создаёт два массива: solid_fragment : array<uint> с SPIR-V и solid_fragment_reflect : array<uint> с reflection. Из reflection можно узнать стадию шейдера и используемые ресурсы. Имя функции в исходнике — fragment_main, но сгенерированная точка входа SPIR-V называется main.

Создание SDL GPU shader#

Когда устройство создано, оба массива передаются в with_gpu_dsl_shader. Этот фрагмент использует определения из предыдущего примера:

require dassdl3/sdl3_shader_dsl
require dassdl3/sdl3_scope

def inspect_shader(device : SDL_GPUDevice?) : SdlStatus {
    return sdl_scope() {
        var shader : SDL_GPUShader? = device |> with_gpu_dsl_shader(solid_fragment, solid_fragment_reflect) |> sdl_use
        print("Fragment shader created\n")
        return sdl_ok()
    }
}

Обёртка читает reflection, проверяет соответствие ресурсов соглашениям SDL и заполняет SDL_GPUShaderCreateInfo: стадию, количество uniform-блоков и сэмплеров. SPIR-V преобразуется из массива слов в массив байтов и передаётся в обычную функцию создания shader. Время жизни полученного объекта задаётся уже знакомой областью with_*.

Код и reflection должны происходить из одной компиляции. Reflection помогает заполнить параметры создания, но приложение по-прежнему отвечает за совместимость vertex и fragment shader, форматы данных и настройку графического пайплайна.

Можно также использовать уже собранные шейдеры, без стадии компиляции.

Передача uniform-структур#

Структуру Tint можно использовать и на стороне приложения. Но отправлять её обычное представление в памяти напрямую нельзя: GPU ожидает раскладку std140. Для этого есть адаптер упаковки:

require dassdl3/sdl3_shader_uniforms
require dassdl3/sdl3_try

def set_tint(command : SDL_GPUCommandBuffer?) : SdlStatus {
    let params = Tint(color = float4(0.25, 0.5, 0.75, 1.0))
    command |> push_gpu_dsl_fragment_uniform(0u, params) |> sdl_try
    return sdl_ok()
}

Структура приложения должна совпадать с объявлением в шейдере: обёртка не определяет активный shader по command buffer. Для повторной упаковки без выделения нового временного буфера есть pack_gpu_dsl_uniform с массивом байтов, который можно переиспользовать.

Compute и графические backend#

Тот же механизм работает для compute shader: аннотация [compute_shader] генерирует SPIR-V и reflection, а with_gpu_dsl_compute_pipeline создаёт SDL compute pipeline, получая из reflection размеры рабочей группы и количество ресурсов. Для storage-ресурсов дополнительная аннотация sdl_shader_access фиксирует режимы чтения и записи, а адаптеры std430 позволяют упаковывать массивы структур в storage buffer.

Прямой путь с SPIR-V используется для Vulkan. Для D3D12 есть отдельная интеграция с SDL_shadercross: with_gpu_dsl_shader_cross и with_gpu_dsl_compute_pipeline_cross переводят тот же SPIR-V в нужный backend-формат. Этот путь требует shadercross и соответствующих компиляторных зависимостей.

Статьи про шейдеры в SDL:
https://moonside.games/posts/introducing-sdl-shadercross/
https://moonside.games/posts/layers-all-the-way-down/

Интеграция с языком и экосистемой#

Jit/AoT#

daslang не просто скриптовый язык. У него есть режимы JiT-компиляции (на платформах где это доступно), часто разгоняет интерпретируемую версию в несколько раз. Там, где недоступен JiT, есть транспиляция в С++-код. Это тоже поддержано и покрыто тестами, чтобы не ломалось при изменениях.

Live-режим#

Язык также поддерживает работу с live-reload. Однажды запустив приложение с пустым окном, можно дописывать его функционал без перезапуска. Механизм описан в Running it live.

В SDL-примерах окно, renderer и контекст ImGui принадлежат нативному host и сохраняются при перезагрузке скрипта. Модуль live_watch_boost отслеживает изменения файлов и запрашивает reload после сохранения. Значения с аннотацией @live восстанавливаются при инкрементальной перезагрузке; полный reload сбрасывает состояние скрипта.

Примеры на GitHub:

  • 01_widgets.das — SDL/ImGui-приложение с автоматической перезагрузкой и управлением через stdin/stdout.
  • 02_widgets_http.das — управление тем же приложением через локальный HTTP API; к нему можно подключить MCP-клиент.
  • 03_widgets_recording.das — вариант с записью кадров в APNG.
    Минимальный фрагмент live-интерфейса:
require dassdl3/sdl3_imgui_widgets
require dassdl3/sdl3_boost
require dassdl3/sdl3_try
require sdl3_live_demo
require live/live_vars
require live/live_watch_boost

var @live edits = 0

def frame() : SdlStatus {
    let renderer = demo_renderer()
    imgui_widgets_new_frame()
    window(MAIN, (text = "SDL live widgets", closable = false,
                  flags = ImGuiWindowFlags.None)) {
        button(INCREMENT.PUBLIC, (text = "Increment"))
        if (INCREMENT.clicked) { edits++ }
        text("Edits: {edits}")
    }
    renderer |> clear() |> sdl_try
    imgui_widgets_render(renderer)
    renderer |> present() |> sdl_try
    return sdl_ok()
}

Тесты и автоматические туториалы#

Для автоматизации интерфейса используется связка с imgui_playwright из daslang. Это сценарный API для ImGui-приложений: виджеты доступны по именам вроде MAIN/INCREMENT, можно получить snapshot, выполнить клик или перетаскивание, дождаться изменения значения и запросить reload.

Например, после подключения app к HTTP-примеру можно проверить, что клик сработал и его результат пережил перезагрузку:

var snap = wait_for_render(app, "MAIN/INCREMENT", 10.0)
let clicks = widget_payload_field(snap, "MAIN/INCREMENT", "click_count") ?? 0

post_command(app, "set_user_control", JV((enabled = false)))
click(app, "MAIN/INCREMENT")
verify(wait_for_int_value(app, "MAIN/INCREMENT", "click_count", clicks + 1))

reload(app)
verify(wait_for_render(app, "MAIN/INCREMENT", 10.0) != null)
verify(wait_for_int_value(app, "MAIN/INCREMENT", "click_count", clicks + 1))
post_command(app, "set_user_control", JV((enabled = true)))

Полный playwright_widgets.das также перетаскивает ползунок синтетическими событиями мыши и проверяет его значение после reload. Автоматический тест дополнительно сравнивает пиксели интерфейса, чтобы подтвердить изменение отрисовки.

Тот же сценарий можно использовать для записи демонстрации или туториала. record_widgets.das запускает последовательность действий внутри with_recording_app: делает паузы, двигает ползунок, нажимает кнопку и проверяет результат. Приложение захватывает кадры через SDL, а dasStbImage записывает их в APNG. На Windows сборку с поддержкой записи можно запустить одной командой из корня репозитория:

.\examples\live\record.cmd

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

Интеграция с daspkg#

Библиотеку можно поставить через daspkg в готовом виде, без генерации привязок и сборки (хотя в исходниках тоже есть уже сгенерированные привязки, чтобы не нужно было тащить LLVM и Clang).

Платформы и графические бэкенды#

У библиотеки есть отдельные профили для Windows, Linux, macOS и браузера. Общие boost-модули используются поверх привязок, учитывающих ABI и доступные функции конкретной платформы.

Платформа Что поддержано
Windows x64 Нативная сборка MSVC, интерпретатор и AOT; GPU-примеры для Vulkan и Direct3D 12.
Linux Core-профиль с GCC, интерпретатором и строгим AOT; запуск 2D-примеров в Ubuntu/WSL2 через WSLg. GPU требует рабочего Vulkan-устройства.
macOS Нативная сборка Apple Clang, Cocoa/Metal, интерпретатор и строгий AOT; есть проверки Metal с отрисовкой и чтением результата.
Браузер Сборка Emscripten в WebAssembly: SDL Renderer через WebGL, ввод и аудио. Есть отдельный пример standalone wasm32 AOT.

Графика при этом идёт двумя путями. SDL_Renderer предоставляет готовые операции 2D-отрисовки: текстуры, прямоугольники и геометрию. SDL_GPU даёт управление шейдерами, буферами, графическими и compute-пайплайнами. В браузерном профиле используется Renderer/WebGL; нативные SDL GPU-примеры туда пока не перенесены, WebGPU-бэкенда в закреплённой версии SDL нет.

Для SDL GPU важен и формат шейдеров. Vulkan принимает SPIR-V, Direct3D 12 — DXIL, Metal — MSL или Metallib. В GPU-примерах один исходник на daslang компилируется в SPIR-V: Vulkan использует его напрямую, а для Direct3D 12 и Metal подключается SDL_shadercross. Для пути Direct3D 12 также нужен DXC. Можно передавать и заранее подготовленные шейдеры нужного формата.

Бэкенд можно выбрать через SDL_GPU_DRIVER: vulkan, direct3d12 или metal.

SDL можно использовать и вместе с самостоятельными привязками Vulkan и OpenGL, доступными в daslang через модули dasVulkan и dasOpenGL. Тогда SDL отвечает за окно, ввод и взаимодействие с платформой, а приложение вызывает графический API напрямую.

Для OpenGL создаётся окно с флагом SDL_WINDOW_OPENGL и контекст через with_gl_context. После выбора текущего контекста можно использовать require opengl и обычные glViewport, glClear, вызовы отрисовки и загрузки ресурсов. Вывод кадра выполняется через SDL_GL_SwapWindow. Есть пример создания контекста и примеры отрисовки через dasOpenGL, которые в браузере работают поверх OpenGL ES/WebGL 2.

Для Vulkan SDL создаёт окно с флагом SDL_WINDOW_VULKAN, сообщает необходимые instance extensions через vulkan_instance_extensions и создаёт поверхность окна через with_vulkan_surface. Сам instance создаётся средствами Vulkan с включением этих extensions; устройство, очереди, swapchain и команды отрисовки также остаются на стороне Vulkan-кода. Модуль dasVulkan предоставляет доступ к этому API из daslang, а SDL-обёртка — платформенную часть работы с окном и поверхностью. Пример получения extensions и контракты Vulkan interop показывают эту границу. Такой путь требует подключения Vulkan-модуля или собственного нативного host; стандартный SDL runner сам по себе его не добавляет.

Для проверки переносимости есть тесты и CI на GitHub для Windows, Linux и macOS. Проверки сборки, API и AOT дополняются отдельными запусками графических и браузерных тестов. Иначе обновление или добавление чего-то превратилось бы в кошмар.

Примеры#

Для тестов API я портировал несколько примеров из BGFX (с ним хорошо сверять скорость):
Порты примеров bgfx на daslang и SDL GPU. Шейдеры этих примеров также написаны на daslang.

Последний порт — Shadow volumes: сцена с теневыми объёмами и несколькими источниками света. Скриншот запущенного примера с Vulkan-бэкендом:

Также для веб-версии я сделал эмулятор NES в браузере:
https://github.com/spiiin/dasNES
https://spiiin.github.io/dasNES/