Сделал привязку SDL к языку daslang.
- SDL3
- Автоматическое создание привязок
- Идиоматичность
- Пайплайны
- Builder
- Именованная инициализация
- Возвращаемые значения и Result
- Ранний возврат с sdl_try
- Та же идея в других языках
- Время жизни ресурсов через блоки
- События как последовательность
- Pattern matching событий
- Заимствованный доступ к памяти
- Разные типы для разных ресурсов
- Массивы вместо указателей и размеров
- Временное изменение состояния
- Частичный результат вместе со статусом
- Shader DSL
- Интеграция с языком и экосистемой
- Платформы и графические бэкенды
- Примеры
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/