- О проекте
- Функциональность (F)
- Удобство API (U)
- Надёжность (R)
- Производительноcть (P)
- Поддерживаемость (S)
О проекте
Давненько в блоге не было статей из-за усложнения экспериментов, кои требуют теперь и намного больше времени. Я поработал с GUI-тулкитами в разных языках, следующий уровень сложности - не использовать готовый, а написать его самому.
Основная цель - опыт проектирования GUI, графических движков, очень высокоэнтропийных систем, для которых пришлось переосмыслить часть своих архитектурных знаний. Привычные паттерны, SOLID, GRASP на такой сложности ведут себя иначе. По динамике и эволюции сложных систем невозможно найти материалы, кроме как пронаблюдать и прострадать всё самому. Бонусом графика поддерживает математику в стеке. Кроме того, в промышленных GUI есть несколько проблем, над которыми было бы интересно подумать.
Так, абстракции часто скрывают графический конвейер, повышая удобство его использования, но усложняя создание более продвинутой графики. Это сужает круг решаемых задач GUI-тулкита, отчего гибрид его с игровым движком выглядит интереснее. Граница между ними тонкая: в игровых движках обычно есть GUI, но чем больше графики, тем дальше от мейнстримных GUI-тулкитов. Графике нужно выжать из железа максимум, когда как GUI, наоборот, сэкономить, не тратя ресурсы и заряд батареи. Баланс может быть очень разным, что и создаёт основную проблематику такого проекта.
Я начал искать примеры архитектур, интересный представитель был найден в Haxe, где столкнулся с отсутствием нужных мне GUI-контролов у HaxeFlixel. Однако легко интегрировал в него Feathers UI из-за общей основы в виде OpenFL, под которой был Lime.
Тогда в основе тулкита графический движок, над которым GUI. Оно и логично. Вопрос к 3D, что для 2D и GUI малополезно, но трёхмерная математика в базовом спрайте упростила бы будущее расширение до 3D. Третью координату можно занулять или считать индексом слоя. Но она усложняет контролы, вызовы библиотек, реюз стороннего 2D-кода. И хотя 2D и 3D спрайты можно унифицировать, сведя к одному - 3D, но сходу заложить точку расширения здесь не так просто.
Без этого на ум приходит вариант эволюции: 2D-движок -> GUI-тулкит -> 3D-движок (уже с GUI). Без 3D можно было бы обойтись. Игры меня не интересуют, но к ним близки симуляторы с возможностью тренировать самые разные науки, расширяя свои знания. Граница игра-симулятор также тонкая: песочницы, симуляторы альтернативной физики, научно-фантастические жанры соединяют как развлечение, так и строгие предметные области со сложной математикой. При этом часть их (например, астрономия) плохо дружит с 2D. И 3D в симуляторах делает проект в разы полезнее.
Здесь есть огромнейший простор для экспериментов, так что летом 2022 года идея получила жизнь. На Dlang. Системный стек перспективен для некоторых типов IoT-железа, где приоритет - Linux, хотя кроссплатформенность тоже интересна. Однако следом это требует упрощения. В масштабных объектных иерархиях выше шанс null-указателя c сегфолтом, композицией или паттернами легко устроить взрыв аллокаций, нагружающих кучу со сборщиком. Сложная система отрисовки с многопоточностью не даст адаптировать тулкит под железо и прочие, прочие ограничения.
На сегодняшний день он всё ещё в стадии экспериментального прототипа из-за моего острого дефицита свободного времени, как и работы над другими проектами. Но спешить особо некуда, результатом доволен, краткие промежуточные итоги подвести вполне можно, пробудив уснувший на несколько лет блог.

Видны шероховатости, но в таких проектах нужно как можно быстрее получить прототип для оценки взаимодействия подсистем друг с другом. Полировка контролов сейчас не имеет особого смысла. Функционал постоянно видоизменяется, частично удаляется, оставляя после себя что-то и так по кругу, приводя систему в частично согласованное, но нестабильное состояние.
Снизить нестабильность проблемно - это отказ от изменений, а значит и улучшений. Сведение потоков данных к однонаправленному, иммутабельность, пурификация, "let it crash" (или же do or die, "принцип самурая") и т.п. глобально здесь неприменимы, что намекает на возможное усложнение контроля внутреннего состояния и обратных связей - аналог гомеостаза биологических систем и инженерного САУ.
Мне встречалось похожее в эмбеддеде, где у подсистем было "здоровье". Но там система была проще по структуре и взаимодействию, нельзя перенести её в сложную GUI-логику с массой побочных эффектов, усложняя ещё больше. Без полного решения остаются лишь частичные, локальные меры: конечные автоматы, предохранители, контракты, всё предыдущее и похожее. И это самая острая проблема, требующая добавления компенсаций, от которых тоже будет расти сложность, создавая замкнутый круг.
Из-за масштаба проекта охватить все нюансы попросту невозможно, а много интересного функционала ещё не реализовано. Бегло пробежимся по тому, что есть, в виде уже полюбившейся FURPS:
Функциональность (F).
Графический движок на пустом месте не построить, нужен фундамент, инфраструктурный слой - конфигурация, логирование, жизненный цикл компонентов, глобальный перехват исключений и прочая универсальная логика. Такое ядро в блоге уже обсуждалось много раз. Если позже оно будет использоваться и в других, например, сетевых приложениях, то может потребоваться ограничение системных вызовов для сандбоксинга. Много неотключаемого функционала и побочных эффектов превратится здесь в проблему.
Также нужна мультимедийная библиотека. Если отказаться от кроссплатформенности, то можно обойтись и без неё, однако на этапе прототипа это как сэкономит время, так и наработает интерфейсы, не заставляя выдумывать их с нуля. В дикой природе есть много их с разным уровнем абстракции: GLFW, SFML, Allegro, Raylib, OpenFL, Kha, MonoGame и прочие.
Выбор пал на SDL. Категории API - почти готовый чеклист функционала, могущего быть в движке: рисование, управление окнами, обработка ввода, буфер обмена, диалоги, аудио, камера, данные о железе и т.д. Ещё один удобный чеклист движкового функционала есть у Godot в List of features. Самый базовый функционал 2D-движка хорошо описан в книге Capellman, Salin - MonoGame Mastery (есть русскоязычная книжка-перевод).
Но более специализированные GUI-тулкиты потребляют меньше ресурсов, могут поддерживать больше бэкендов. Логично пытаться упростить GUI-часть, сохранив фиксированный конвейер, для чего подходит SDL_Renderer. Контролы и отрисовка упрощаются, позволяя легче поменять бэкенд и запускаться на старых оптимизированных драйверах. А для 3D удобен уже SDL GPU в SDL3 с прямым доступом к конвейеру.
Но постоянная перерисовка каждый кадр и SDL_PollEvent всё равно поднагружают процессор в сравнении с экономными GUI-тулкитами. Хотя в них есть и всякие оптимизации навроде временной приостановки опроса событий, сложной системы инвалидации и т.п. хитростей. Но пока оптимизировать рано: нет ещё 3D, которое запросто может в эти оптимизации упереться.
Следом сразу порождается разброс частей движка между разными API и проблема смешивания старого SDL_Renderer с SDL GPU, когда нужно 3D и GUI одновременно. В текущем варианте использую SDL_RenderTexture для отрисовки 3D сцены с дорисовыванием туда GUI-контролов. Но производительность очень плохая.
Сложные контролы трудоёмки на примитивном 2D. Базовые фигуры можно отрисовать простыми алгоритмами навроде Брезенхема, но дальше - проблемнее, как и под вопросом эффективность рисования рендерером. Возможно, в будущем есть смысл убрать разброс и перевести 2D на SDL GPU, но там нет OpenGL, что пока ещё может быть проблемой. Вопросы и к потреблению памяти, где в зависимости от драйвера она может прыгать значительно в новых графических API. Для GUI-тулкита это не очень хорошо.
Более-менее красивое рисование сразу требует канваса. Популярная Skia выглядит немного проблемной. На момент её изучения не было стабильного C-api, а отзывы противоречивые в т.ч. из-за фундаментальной проблемы, вытекающей из главного преимущества: рисование на GPU делает конвейер движка условно распределённым, собственный и библиотечный. Подходит для GUI-тулкита, но для графического движка такая схема проблемнее.
Так что пришлось взять Cairo. Рисовать на CPU можно в статичные GPU-текстуры, конвертируя через SDL_Surface, чего достаточно для отрисовки фонов и эффектов контролов. Вообще для IoT девайсов с небольшими экранами и отсталой видеокартой CPU-рендеринг может быть и не таким бесполезным, как кажется.
Если завернуть Cairo в интерфейс и сблизить его с Canvas API у JavaScript, то удобно адаптировать примеры из веба. Правда, это завязывает канвас на immediate mode рисование, но не все рисовательные библиотеки такие. И встроенных эффектов у Cairo нет. Без Гаусса тот же неон (в примере условный, обычно нет двойного контура) требует многослойной отрисовки контура:

Раз появилось графическое API, то логично его немного развить, погоняв на практических задачах. Но что может такой примитивный движок с одним лишь канвасом... Я начал искать, что реализуется очень просто, а выглядит интересно.
Например, простые и эффектные математические кривые и процедурные алгоритмы: Лиссажу, розы, циклоидальные кривые (получается спирограф), суперформула, системы Линденмайера, фракталы (многие из них, как и L-системы, легко рисуются черепашкой, много простых и интересных, навроде Hopalong, они же Аттракторы Мартина), мозаики (например, Пенроуза), что тесно пересекается с генеративным искусством.
Процедурная генерация не обходится без шумов. Понравились проекты Procedural-Noise для Unity (MIT), fractal_cell_noise (MIT), которые легко портируются.
В части лабиринтов легко реализовать алгоритм двоичного дерева, Sidewinder, как и Олдос-Бродер. Даже в самом примитивном своём виде они могут выдать некие красоты. Есть несколько статей на хабре Классические алгоритмы генерации лабиринтов. Часть 1: вступление, и её вторая часть и т.д. Также понравилась обзорная статья Алгоритмы для создания лабиринтов из книги Mazes for Programmers. Встречаются и визуализации, ещё. Нельзя забывать графы, клеточные автоматы и прочее похожее. Визуализировать можно что угодно.

Легко добавить триангуляцию Делоне и диаграммы Вороного, для чего присмотрелся к проекту delaunator-sharp (MIT), портировав его.
Или же создать интересные ландшафты через Diamond-Square алгоритм, симпатичные горы через Midpoint displacement. Как и нарисовать звёздное небо квазислучайными последовательностями.

В демках пригодились и булевы операции над многоугольниками. Поиски привели к проекту polybooljs (MIT) и я портировал один из старых Java-портов.
В генеративном искусстве тоже много полезного, например, Tyler Hobbs - Flow Fields. На хабре есть перевод, как и ещё интересная статья уже о векторных полях.
Простейшие цветовые эффекты удобны через HSV, HSL и схожие модели, где по таймеру или анимации изменяется значение тона, насыщенности, яркости и прочего. В HSL значение lightness иногда провоцирует ошибки т.к. чистый цвет - 0.5 или же 50%, что не всегда интуитивно после других систем. Экспериментировал и с более продвинутыми навроде LCH, но из-за тригонометрии в тамошних расчётах она медленнее, что требует предвычислений или приближений. Возможно, посмотрю на более сложные цветовые модели позже.
Сами анимации можно реализовать кривыми Пеннера, они же кривые плавности, tween (от in-between), классических вариантов около трёх десятков. Встречаются повсеместно в движках.
Рисование в текстуры можно обрабатывать как обычное растровое изображение через свой буфер или SDL_Surface, в движке полезен минимальный image procesing. Изначально рассматривал ImageMagick и libvips, но изображения не основная задача движка, можно пробовать обойтись без зависимостей, реализовав всё необходимое руками. Пока совершенно неоптимизировано, на многомерных массивах (проще ловить баги индексации) с проблемами кеш-промахов и векторизации, но для небольших изображений потестировать хватает. Вспомним всеми любимую в цветопроцессинге Лену:

Нужна ещё медиасистема. Анализ и синтез звука укладывается в симуляторы: если процедурно генерируются изображения, то звук тоже может, да и они могут взаимодействовать друг с другом, значит нужны элементы DSP (Digital Signal Processing). FFT есть в D из коробки, FM\AM-синтез, ADSR, простые эффекты реализуются не так сложно. Вернее, ошибки от перепутывания моно\стереоканалов, клиппинга от превышения амплитуды, путаницы между семплами и фреймами, конечно же, будут, но они и легко чинятся. Удобен экспорт в WAV, пишется в пару строчек, но позволяет смотреть результат в Audacity и т.п. программах.
Изначально для аудио я использовал SDL_Mixer, позже переехав на PortAudio. Постепенно движок научился синтезировать и смешивать звук самостоятельно, необходимость в лишних обёртках отпала.
Движку удобнее более низкоуровневая медиабиблиотека. Например, всеми любимая FFmpeg. Пайплайн здесь уже нужно выстраивать самому, что сложнее, но интереснее: появляются проблемы синхронизации видео по аудио, рассинхронизация демультиплексора и видео\аудиоочередей, сложность расчёта задержек аудиоустройства и т.д. и т.п. Не все беды пока решены, ещё много работы и много проблем.
Ко всему этому неплохо бы добавить вишенку на торте - физику. Я бегло потестировал Box2D (3-я версия переехала с С++ на С) и Chipmunk2D. Но пришло понимание, что симуляторный движок обязан обучать, улучшая знания предметных наук, оправдывая этим вложения в себя времени. Так что нужно стараться написать руками.
Здесь мне очень понравились книги Keith Peters - Foundation ActionScript 3.0 Animation. Making Things Move! и AdvancED ActionScript 3.0 Animation. Книжкам много лет, но они очень высокого технического уровня, много интересных идей. Даже есть репозиторий с демонстрациями от автора FeathersUI. Также портировал цикл постов по 2D физике с хабра (в статьях есть неточности, но есть ссылки на исходный код), как и Steering Behaviors, что уже позволило создавать более интересные 2D-демки.
В итоге даже очень простой 2d-движок может выдать уже чего-нибудь красивого, а развитие рисования подготавливает почву для появления GUI-контролов. Можно создавать интерактивные демки, открытки, симуляции, много чего забавного.

Так на первом эволюционном этапе движок прошёл через 2D, накапливая объём алгоритмов и кода. Это прокачало 2D-рисование, но при последующем добавлении 3D обе кодобазы суммируются. Ведя, понятное дело, к деградации от масштаба, что дилемма: одинаково поддерживать 2D и 3D, либо чем-то пожертвовать ради упрощения и облегчения движка.
Я выбрал второй вариант и вырезал часть 2D. В сложных системах нужна не только эволюция, но и обратная ей инволюция, ведущая к упрощению, что тесно связано с уменьшением функционала. Вероятно, часть 2D вернётся для генерации процедурных текстур и билбордов (плоский спрайт, всегда повёрнутый к камере) для 3D, так-то они тесно взаимосвязаны и дополняют друг друга. Подтвердив очередной раз циклический характер развития всех систем.
На таком стеке уже можно рисовать контролы, разными способами: через SDL_Renderer напрямую, захватив его в текстуру, через Cairo, комбинацией всех способов с копированием в итоговую SDL_Texture. Это создаёт разброс в иерархии между разными способами: статичным контролам удобнее текстуры, динамическим - рендерер, а могут быть и гибриды. Сходная механика встречается и в промышленных GUI-тулкитах, например, в том же JavaFX есть флаг setCache, который создаёт битмап из узла, у Avalonia CacheMode.
По-всякому можно и спроектировать иерархии. Электронные аналогии делают кнопку видом более общего переключателя с двумя состояниями. Туда же можно отнести чекбоксы, как и toggle-переключатели. Это делает модуль кнопки подмодулем переключателей, что временами ощущается контринтуитивно. Развернуть ли иерархию контролов в плоскую для удобства, что перемешает модули друг с другом, либо же хранить их привычно, иерархически, используя package.d для быстрого импорта. Пока склоняюсь ко второму варианту.
Из-за такого многообразия контролы пока черновые, прототипные. Требования к ним сильно зависят от платформы. Полностью адаптировать движок к вебу через WASM проблемно, хотя обёртки для SDL - возможно, но толку от этого не так много. К портативным устройствам - интереснее, но могут различаться способы ввода, как и управление сценой. Например, часть современных тулкитов использует абстракцию "pointer" (Avalonia, Flutter и т.д.), я тоже принял её.
Для реализации части их (списки, таблицы) нужен растровый клиппинг - отсечение контента рамкой. В SDL есть из коробки, прямоугольный. Из-за этого они выглядят лучше с прямоугольными границами и ползунками. Самый опасный баг, когда события срабатывают на скрытых, отсеченных узлах.
Использование Cairo и ограничения рисования на CPU автоматически приводят к слоевому (композиционному) устройству контролов. Эффекты показываются и скрываются с анимацией, могут быть текстурами или рисоваться рендерером. Текстурный подход красив, но потребляет больше памяти для хранения этих текстур. Также текстуры разбрасываются по коду, что усложняет оптимизации, в частности построения глобального текстурного атласа. Их также нужно пересоздавать при ресайзе, что неудобно.
Часть контролов примерно одинаковы у многих GUI-тулкитов, другая часть различается. Так, я долго выискивал лучшие способы выбора времени и цвета. У времени победил циферблатный, лучшего найти пока не удалось.
В цветовыбиратель добавил MD2 палитру для снижения трудоёмкости подбора цвета. В ином случае можно бесконечно крутить ползунки, но симпатичные цвета сходу не получаются. Возможно, есть смысл добавить в будущем цветовой круг, либо соединить его как-то с палитрой. HSV изначально не планировал, ибо HSL удобнее, но HSV любят в электронике.

Шрифты копируются в атлас-текстуру, что быстрее при отрисовке, но при любом изменении свойства шрифта нужна новая текстура. Очень сильное ограничение, быть может нужен какой-то гибридный вариант. Хотя множество форм шрифта приложению не всегда нужно. Вообще информации по шрифтам не так много. Долго не устраивало качество, пробовал разные подходы, LCD-режим и прочее. Иногда результат зависим от фона. На одном фоне приемлемо, на другом плохо (как на самом первом скриншоте в статье выше видны искажения на более ярком фоне).

Текстовые контролы внутри используют UTF-32 (тип dstring) для упрощения индексации по символам. Но это затратно по памяти, постоянные переконвертации, из-за графемных кластеров индексация всё равно не будет работать на некоторых языках, эмодзи. После стабилизации алгоритмов есть смысл подход пересмотреть.
Также они пока ещё на массивах. Пробовал Piece Table (есть обзор на хабре), но отладка всех тамошних нюансов требует вагонов времени, которого пока нет.
Опыт в других GUI показал, что растровые иконки - затея противоречивая из-за разных типов экранов, размеров, плотности пикселей. Ранее пробовал растеризацию из SVG, храня иконки в текстовых файлах, где можно добавлять свои кастомные. Здесь тоже можно было бы отрисовать Cairo, но победил вариант с иконочными шрифтами, он проще. Взял Bootstrap Icons.
Один из самых сложных контролов в GUI - веб-браузер, что сам по себе графический движок. Эксперимент с Chromium Embedded Framework показал, что из-за тамошней проверки бинарной совместимости уже на старте нужна обширная инициализация, что взрывной рост обёрток, множество побочных эффектов, мешающих изолировать сегфолты. Часть функционала, например, рендеринг в конвейер, управление внешней петлёй и т.п. системозависимы, так что кроссплатформенность очень условная. libcef при этом весит около 1.5G, что таскать с приложением как-то неудобно.
Поэтому решил сперва потестировать WPE WebKit, но он только под Linux. Некоторые GUI (Avalonia) тоже используют его. Буфер отдаётся через протоколы Wayland, копирование через SDL-текстуру пока не может выдать достаточный перф, позволяющий использовать браузер как самодостаточный движок и GUI-тулкит. Направление интересное и его есть смысл развивать в будущем, что позволяет задействовать инфраструктуру веба.
В SDL3 появились нативные файловые диалоги. Менеджер рабочего стола вставляет в диалог свои меню, закладки и прочие элементы, учесть которые невозможно, создание своих кастомных диалогов идея сомнительная. Трей будет улучшен в SDL 3.6, как и доступ к системным уведомлениям. Пока наблюдаю за их прогрессом.
Часть идей себя не оправдала. Шина сообщений для системы плагинов - много накладных расходов, а перехват всех компонентов сложного движка нетривиален. Механизм привязок (bindings) как в том же JavaFX - тоже оверхед, а обычные слушатели проще, гибче и удобнее, не имеют проблем строгих инвариантов одно\двунаправленности. Встраивание движка в другие языки (тестировал в Dart) - сложность массового проброса функционала движка через C API, что создаёт ещё одну точку изменений, увеличивая размер кодобазы на ровном месте. И т.д. и т.п.
Однако концепция шины напомнила собою, что в GUI-тулкитах встречается абстракция системной шины, на Linux - D-Bus. Она же делает возможной поддержку экранных дикторов, через неё же реализуется трей, слежение за сетевыми подключениями и т.п. Но это в будущем. Несмотря на свои фичи, шина совсем небезобидна: если не заложить конфигурирование общения с шиной без перекомпиляции, то могут быть проблемы. Например, уход сети приложение поймёт, а появление - нет.
DSL-конструкторы как у Flutter стали возможны после появления в D именованных аргументов - но сложность внедрения зависимостей, резкое усложнение контролов отложенной инициализацией сильно понижают полезность. Но можно добавить их частично. Так, контейнеры (пока не все, нужно рефакторить конструкторы) поддерживает детей, что создаёт некое подобие DSL:
auto root = new HBox(
isAlignY : true,
children : [
new Button("Hello1", IconPack.alarm, (ref e) => "Click1".writeln),
new Button("Hello2", IconPack.app_indicator, (ref e) => "Click2".writeln),
new Button("Hello3", IconPack.bicycle, (ref e) => "Click3".writeln)
]);
addCreate(root);
Так что реализовать можно ещё многое, как и переделать старое, было бы времени побольше.
Удобство API (U).
Фундаментальная проблема - общая консистентность, выбор именования геттеров, сеттеров, булевых свойств и прочего, что структурирует движок. После формирования привычки любые особо длинные или неудобные имена начинают раздражать. Некоторые буквосочетания провоцируют ошибки (делали опечатки в heigth, особенно в текстовых вью-шаблонах с выключенной ради скорости проверкой орфографии?). Для GUI проблема острее из-за перехода части API в текстовые файлы шаблонов.
Логика подсказывает: чем чаще встречается API, тем менее вербозным оно должно быть. Движок местами использует популярные сокращения Vec, Tex и прочие. Однако сокращение слов следом уменьшает их понятность. Могут нарушаться и когнитивные паттерны: после сокращения components в comps модулем стало как-то тяжелее пользоваться, хочется писать "compo" при импорте. Более редко используемое API вполне может быть вербозным, отражая максимум побочных эффектов в имени.
Например, alignX может рассматриваться как функция и как флаг. Чтобы не выдумывать, движок использует isAlignX. В старой школе isX используется для булевого геттера, однако в языках со свойствами геттер и сеттер одинаковые.
Можно алиасить для дублирования имён короткими. Движок использует популярные width\height, что легко заалиасить как w и h. Удобно, но использование разных версий создаёт разброс, отчего внутри используется только первая. Нужна аккуратность: при добавлении алиасинга позднее, как и при глобальном переименовании могут встретиться баги затенения переменных и какой-нибудь анонимных класс захватит нулевые размеры.
Так что поддержка консистентности и банального нейминга для сложной и постоянно меняющейся системы - очень большая проблема. При этом как и в любой системе, в движке накапливается рассинхронизация. Где-то код использует старый нейминг и архитект, где-то - новый. Рефакторинги периодические, полная консистентность сейчас невозможна из-за постоянных изменений (возможна ли она в сложной системе вообще - вопрос философский).
Также важно сохранить удобство популярных объектных движков, кои по дизайну расходятся - больше наследования или композиции. Снижение аллокаций, упрощение кастомизации, структуры, взаимодействий объектов требует наследования. Анонимные классы повышают удобство, хотя при неаккуратной работе могут создать некий шанс утечки от замыкания на внешний класс, так и путаницу между контекстом переменных, как отмечалось выше.
Сделаем на анонимных классах что-нибудь особенно чудовищное, например, заставим горизонтальный контейнер вести себя как вертикальный, перерисуя заодно его фон.
auto hbox = new class HBox
{
this()
{
super(isLayout : false);
layout = new VLayout(isAlignX : true, isAutoResize: true);
isBackground = true;
}
override Insets themePadding() => super.themePadding.mul(5);
override Sprite2d newBackground()
{
return new class VecTex
{
this()
{
super(width: 1, height: 1); //своих размеров контейнер ещё не знает, может быть 0 с последущем авторесайзом, но текстура векторная, будет растягиваться, но и перерисовываться
}
override void createContent()
{
auto ctx = canvas;
const centerX = halfWidth, centerY = halfHeight;
auto bg = ctx.createRadialGradient(centerX, centerY, width / 3, centerX, centerY, halfWidth); //длинноватое название, но как у HTML Canvas API
bg.addColorStop(0, RGBA.hex("#004D40")).addColorStop(1, RGBA.hex("#00BFA5"));
ctx.radialGradient(bg, () => ctx.fillRect(0, 0, width, height));
}
};
}
};
addCreate(hbox);
hbox.enablePadding;
Sprite2d[$] btns = [new Button("Hello 1"), new Button("Hello 2")]; //статический массив, чтобы не создавать динамический. Пока шаблонизировать addCreate(sprite1, sprite2... проблемно. В последних версиях компилятора размер массива можно заменить на $
hbox.addCreate(btns);
Нарисуется примерно такое:
.
Зачем вообще возможность отключения компоновщика у HBox. Если создать компоновщик внутри конструктора, то его можно настраивать сразу после создания объекта, что удобно. Однако завязывает конструктор на использование сборщика, когда как компоновщик может быть в другой памяти, да и быть общим для разных контролов. Его можно заменить после конструктора, но создание лишнего объекта. В конструктор можно передать и фабричный делегат, базовый контейнер поддерживает такое, но это не основная задача HBox.
Выходит, что контрол - самодостаточный контейнер, в который можно вкладывать другие контролы, а также способный компоновать их. Горизонтальный контейнер лишь предустанавливает компоновку, но не запрещает изменять её.
auto root = new HBox(isAlignY : true);
addCreate(root);
root.addCreate(new Button("Hello1", IconPack.alarm));
auto btn2 = new Button("Hello2", IconPack.app_indicator);
btn2.layout.isAlignReverse = true; //не нужно собирать контрол, компоновщик доступен после конструктора, что удобнее
auto btn3 = new Button("Hello3", IconPack.bicycle, isLayout : false);
btn3.layout = new VLayout(isAlignX : true, isAutoResize : true); //компоновщик можно поменять
root.addCreate([btn2, btn3]);

Большой вопрос, где должен быть компоновщик - в GUI или же во всех спрайтах. Пока тестирую второй вариант, GUI-контролы могут собираться с участием не GUI частей, которым тоже может нужно выравнивание. Возможно и убрать создание объекта компоновщика из конструктора, чтобы по всем контейнерам не пробрасывать флаг isLayout. Как всегда: что так неудобно, что эдак.
Анонимные классы местами очень полезны, но, помимо риска перемешивания контекста переменных, по мере разрастания логики сопровождать это трудно в т.ч. из-за скобок. Большое количество скобочного шума и отступов сходны с таковой проблемой в некоторых современных DSL-тулкитах. Можно использовать обычные, статические (вернее, вложенные) классы, но они тоже не всегда удобны.
Тогда на помощь приходят делегаты. Делегат может создать объект, донастроить или откатить изменения после конфигурации. Альтернатива с делегатами такая:
auto hbox = new HBox(isLayout: false);
with (hbox)
{
layout = new VLayout(isAlignX : true, isAutoResize: true);
isBackground = true;
onPadding = (insets) => insets.mul(5);
onNewBackground = () {
auto texture = new VecTex(width: 1, height: 1, onContentContinue: (ctx) {
const centerX = halfWidth, centerY = halfHeight;
auto bg = ctx.createRadialGradient(centerX, centerY, width / 3, centerX, centerY, halfWidth);
bg.addColorStop(0, RGBA.hex("#004D40")).addColorStop(1, RGBA.hex("#00BFA5"));
ctx.radialGradient(bg, () => ctx.fillRect(0, 0, width, height));
return false; //рисовать или не рисовать что-то другое на канвасе
});
return texture;
};
}
addCreate(hbox);
hbox.enablePadding;
hbox.addCreate([new Button("Hello 1"), new Button("Hello 2")]);
Делегаты в D удобны и похожи на таковые в высокоуровневых GUI-тулкитах, но большое их количество как раздувает размер класса, так и начинает конфликтовать с внутренним состоянием и поведением. Так, выше делегат прерывает отрисовку. Так что много их быть не может, где-то удобнее делегат, где-то - наследование. Для гибкой настройки даже просто объекта требуется огромное количество точек перехвата и делегатов в них.
У дизайна с наследованием есть и свои недостатки. Базовый спрайт будет в какой-то степени перегружен функционалом. И его большой размер ведёт к неэффективному расходу памяти, как и усложняет дальнейшее расширение.
Для языков с одиночным наследованием иерархию легко заблокировать. На это случай у компонентов движка есть простой локатор служб. Самим движком не используется, его антипаттерновая природа не вредит. В базовых классах удобен и ассоциативный массив пользовательских данных. Если локатор пробрасывается от компонента к компоненту, то для массива в этом случае нужны механики слияния, чтобы не перезатереть существующее. Это намекает на разные хранилища, хотя, конечно же, можно и унифицировать их.
Изоляция SDL, внедрение зависимостей, опасность исключений, уменьшение аллокаций и нагрузки на сборщик мусора требуют разделить движок как минимум на два слоя. API верхнего уровня должны работать на исключениях, не обременяя обработкой ошибок, а также активно использовать память. Опасные, более медленные, но зато очень удобные и хороши в прототипировании.
С другой стороны, более низкоуровневые на возвратах ошибок, трудоёмкие, но они более стабильны и предсказуемы, как и производительны. Здесь просится атрибут @mustuse, заставляя обрабатывать ошибку всегда, пробрасывая её от SDL наверх. Идея не очень удобна на самом деле. Длинные полотна обработки ошибок и появление накладных расходов от таких проверок:
auto saveRedImage = () nothrow {
scope ComSurface image = new SdlSurface; //объект на стеке
if (const err = image.createRGBA32(100, 100))
{
return err;
}
if (const err = image.fill(r:ubyte.max, g:0, b:0, a:ubyte.max))
{
return err;
}
if (const err = image.saveBMP("/home/user/red_image.bmp"))
{
return err;
}
return ComResult.ok;
};
if (const err = saveRedImage())
{
//что-то сделать с ошибкой
}
Как видно, исключения (но Error бросить возможно, это невосстановимая ошибка) движок обязуется не бросать из-за nothrow. Из-за аллокации класса на стеке его деструктор будет вызван автоматически после выхода из функции, объект уничтожается, что более детерминированно в отличие от сборщика. Можно добавить и @nogc (да, аллокация на стеке разрешает не nogc-конструкторы), но для push-модели ошибок, когда они выталкиваются функцией, нужно управлять памятью строковых ошибок из SDL. Сейчас пока так.
Из-за такой трудоёмкости движок идёт на компромисс: для часто используемой логики возврат bool-типа, ошибку нужно проверять вручную. В некоторых случаях в nothrow-функциях выбрасывается Error при нарушении критических инвариантов памяти, например, на null-указателе. Баланс между этими способами пока ещё требует множества экспериментов, демонстрируя, что выбрать какой-то один - невозможно. Либо будет хорошо, но неудобно, либо удобно, но плохо.
Поскольку для удобного использования нижние слои должны оборачиваться верхними, то это несколько точек изменений, оверхед от вызова, как и размножение кодобазы. Несмотря на иллюзию защиты от изменений, для максимально эффективной работы слоёв их нужно синхронизировать по типам и поведению, что изменение обоих в любом случае. В итоге: интерфейс, нужный для внедрения зависимостей, sdl-обёртка, класс движка, в котором она живёт - три точки изменений. Условно две, интерфейс можно переименовать вместе с sdl-обёрткой. И если абстракции облегчают небольшие изменения и замену подсистем, то усложняют масштабные.
Даже несколько точек изменений - это много при сильном изменении библиотек. Простейшие абстракции сделали мне больно при переезде на SDL3. Вернее, переехал я достаточно быстро, но поломок было много, намекая на неудачность этого классического подхода из-за повсеместного удваивания точек изменений. Нет обёрток - плохо, ничего не подменить, но много обёрток - тоже плохо, усложняются глобальные изменения, страдает перф. Снова нужны компромиссы.
Простой вариант - использовать низкоуровневый слой напрямую, не имея класса для него в высокоуровневом слое как в примере выше. Подходит для SDL_Surface, где не требуется сложной логики. Это немного сократит количество точек изменений, местами нарушая консистентность, как и удобство. Запрет там сборщика через @nogc ещё больше усложнит использование и без того трудоёмкого API, особенно где нужны строки. И обёртки можно отвязать от структур движка, например, цвета. В метод выше передаётся ubyte, а не структура RGBA.
В D интерфейсы созданы для классов, но не для структур. В бэкенде движка же удобнее структуры, но бэкенд предполагает расширяемость, нужна эмуляция объектной парадигмы. Да и обёртка над SDL-указателем семантически не является объектом-значением. Количество компонентов бэкенда велико, что в случае метапрограммирования или шаблонов может дать непредсказуемую деградацию времени компиляции. Учитывая, что простые классы можно аллоцировать на стеке (но менее эффективно в сравнении со структурами), то я пока оставил классы. Есть ещё проблема.
Комбинирование двух слоёв даёт композицию. В данном случае без композиции нельзя обойтись, но она, вместе с аллокацией, создаёт проблему детерминированности вызова деструкторов, как и в целом конфликтов двухуровневых вызовов, где кто-то может быть в невалидном состоянии. Добавим сюда соблюдение инвариантов родителя при наследовании. И это только начало больших проблем.
Так мы плавно подходим к внедрению зависимостей, где компоненты бэкенда должны пробрасываться сверху вниз. Упрощённо зависимости бывают внутренние и внешние. За внутренние класс отвечает и создаёт сам, их можно подменить наследованием, подсунув мок для тестирования. Сборка такой зависимости в руте приложения со спуском вниз нарушает принцип информационного эксперта - классы приложения не знают, что нужно кнопке. Да и технически собрать всё инстанцирование где-то в одном месте нетривиально. Эти зависимости не создают проблем, никто обычно с ними ничего не делает. Разве что фабричные методы должны быть отделены от остальных. Проблемы создаёт другой вид.
Кнопка не может сама создать логгер, тему, конфиг и начать парсить их файлы, что задача уровнем выше. Конфиг спускается кнопке сверху, как и множество других служб\сервисов\зависимостей. При этом не все зависимости уровня приложения, которые можно поместить в статику и забыть о них. Компонент может добавлять новые, внутренние для него, но внешние для всех вложенных. В очень простом случае результат вызова конфига (как и любой службы) можно прокинуть кнопке напрямую, но по мере усложнения трудоёмкость проброса множества значений быстро сводит идею на нет.
Внедрение через конструктор или сеттер мгновенно усложняет жизненный цикл объектов. Вызов может опираться на зависимость, а она невалидна. Нельзя просто так вызывать методы, они должны вызываться с определённой очередностью. Да, это конечный автомат. При этом невалидные состояния сложно отслеживать, на них проще выбрасывать ошибку. Но такие ошибки легко повышают хрупкость всей системы, бонусом усложняя использование компонентов движка.
Необходимость автомата порождается не только внедрением. Он удобен для любого более сложного жизненного цикла. Например, ленивое создание контента в табах, остановка и запуск анимаций при переключений табов, приостановка сцены и т.д. и т.п. Его иногда добавляют и в сами контролы для управления интерактивностью. Это упрощает код, исключает невалидные состояния (например, деактивирована и нажата одновременно). Но есть и проблемы: комбинаторный взрыв состояний, взаимодействие с анимациями, жёсткая таблица переходов. Пока контролы на обычных флагах.
Поэтому метод addCreate отражает два побочных эффекта - добавление дочернего спрайта и его переход в другое состояние. add(new Button) второго не имеет, create(new Button) не имеет первого. Семантическое двоесмыслие: он сначала добавляет, а потом вызывает .create у спрайта, либо же наоборот. Второе. При создании может вылететь исключение и бракованный спрайт останется у родителя в непредсказуемом состоянии. И на самом деле побочных эффектов там ещё больше: спрайт может уже кем-то быть собран, проинициализирован или нет, собран частично, уже быть добавлен и т.д и т.п.
Если много побочных эффектов, то такой метод было бы назвать createChild (более популярный addChild уже есть в виде простого add). Вообще соединение двух глаголов встречается в API не так уж и часто. Но пока addCreate выигрывает дубовой простотой, удобством набора, как и консистентен с другими именами. Опять-таки, нейминг ещё не стабилизировался.
auto sprite = new Sprite2d;
sprite.create;
sprite.initialize; //более короткое .init используется самим D, пришлось сделать initialize, уходить от инициализации в имени не хотелось
Что должен делать движок в этом случае? Он может продолжить работать, но легко получить опасные ситуации - двойные освобождения памяти, дублирования данных в массивах, null-указатель. Надёжнее выбрасывать ошибку. С другой стороны, в безобидном коде, навроде бесполезных информационных сообщений вылет ошибки может уронить приложение, намекая сделать автомат отключаемым. Так в случае аварии приложение можно запустить и использовать без постоянных падений. Но нужно пробрасывать настройки автомата по всей иерархии и пока поддерживается лишь для спрайтов.
При этом заложить универсальные переходы и стратегии их обработки проблематично, каждая ситуация требует каких-то частных случаев, что даст баги ещё и в этих состояниях, усложняя конечный автомат. Как проблемно и прикреплять автомат по необходимости, что легко забыть.
Очень коварный баг: в методе, который меняет состояние запустить другой метод, меняющий состояние, которое сразу же затрёт предыдущее и движок запутается. Близка тут и ABA-проблема. Из-за наследования легко упустить вызов родительского метода смены состояний: super.create. Сам метод отработает, но движок бросит ошибку, ведь для него никакой смены состояний не было. При этом наследование не даёт усложнить сигнатуры или что-то возвращать из функции, иначе наследоваться неудобно.
Частный случай - объект не перешёл ни в одно из состояний, что бывает сразу после его создания. Функции, которые опираются на зависимости могут (вернее, должны) проверять состояние с выбросом понятной ошибки, но это очень трудоёмко. Пока местами банальная проверка на null.
Есть подход объединения конечного автомата с поведенческими деревьями (Behavior Tree), но сложность от их обоих просуммируется, что может быть хуже. Здесь нужно больше экспериментов.
Внедрение создаёт проблему контейнера. Можно запросто избавиться от него, встроив его прямо в базовые компоненты. Любой компонент может быть точкой сборки зависимостей, комплектуя ими другой компонент, который уже работает как контейнер, собирая другие по цепочке.
auto sprite = new Sprite2d;
build(sprite); //теперь зависимости проброшены
sprite.build(new Sprite2d); //может пробрасывать дальше
Как в любых простых идеях, здесь кроется много дьявольских деталей. Есть два компонента - кнопка и более простой спрайт. У кнопки есть тема, но её нет у спрайта. Кнопка может собрать спрайт, зависимостей больше, но спрайт не может собрать кнопку, там зависимостей меньше.
Внедрение должно наследоваться, чтобы добавлять новые зависимости. И это требует хитроумных перегрузок, повышая шанс собрать что-то не так. Здесь на помощь приходит сам D в виде интересной особенности. Временами она мешается, заставляя всё на свете принудительно алиасить. Но в данном случае сильно помогает. С другой стороны, временами проще использовать нисходящее приведение типов и скастить объект в наследуемом методе, который точно вызывается, не связываясь с перегрузками. Перегрузки запросто могут устроить ад.
Если внедрение и жизненный цикл со строгой сменой состояний усложняет сборку и использование объектов, то могут помочь фабрики. Например, auto image = f.image("/path") вернёт уже собранный и подготовленный объект. Но на все объекты лепить фабрики проблемно и теряется контроль за настройкой. Пока они себя как-то не особенно оправдывают.
Более удобный способ - UDA, они же аннотации. Как и в других GUI-тулкитах, как и в том же JavaFX, при загрузке сцены из файла (или без) через метапрограммирование в аннотированные поля сцены можно инжектить уже собранные объекты, возиться руками не нужно:
@Load(path : "container.jpg", width : 100, height : 100) //подумываю над @Inject, но Load как-то проще
Image image;
@Load(path : "container2.bmp", count : 2)
Image[] images;
Удобно, но у обширного для поддержки всех контролов метапрограммирования будут штрафы на время компиляции, сейчас поддерживается лишь несколько типов для тестирования этой механики. Да и универсальные настройки заложить в аннотации проблемно. Наибольшее удобство тут, конечно же, при загрузке сцены из файла.
Но о формате думать пока рано, нужно узнать требования более сложных и нагруженных 3D-сцен. XML - классика для многих GUI-тулкитов, но могут быть проблемы с избыточностью и скоростью парсинга. Cairo может рисовать в SVG, что позволяет хотя бы частично просматривать сцену (например, цвета) в просмотрщиках изображений. И хотя файл становится огромным даже на очень простых сценах, но это интересная возможность, которую можно включать и отключать по необходимости. Так что SVG - первый претендент.
Должно ли внедрение зависимостей в виде того же build быть частью автомата состояний - вопрос открытый, пока - нет, не во всех случаях зависимости пробрасываются явно, их можно объединить с инициализацией и созданием, упростив конечный автомат на одно состояние.
Управлять спрайтом можно по-разному:
auto root = new HBox;
addCreate(root);
auto btn = new Button;
root.addCreate(btn); //просто отдать контрол кому-нибудь. Но можно захотеть его настраивать, а вот зависимостей там нет, если он их как-то использует.
auto btn1 = new Button;
build(btn1); //теперь зависимости есть, можно настраивать
btn1.initialize; //вроде как и проинициализирован, но движок может проверить лишь некорректное состояние атомата, но не может защитить от его несрабатывания.
btn1.create;
root.add(btn1);
auto btn2 = new Button;
buildInitCreate(btn2); //движок проверяет, что переход спрайта в состояние состоялся, вставляя проверку после каждого перехода
root.add(btn2);
auto btn3 = new Button;
btn1.build(btn3); //собирает кто-то один
btn2.initCreate(btn3); // инициализирует и проверяет это кто-то другой
root.addCreate(btn3); // создаёт-добавляет кто-то третий, оценивая собран ли уже кем-то и т.д. комбинации
Добавление всё новых и новых типов компонентов: 2d-спрайт, gui-спрайт, 3d-спрайт и т.д. требует тюнинга сборки, внедрение усложняется, провоцирует баги, где один не может сообрать второго, объект попадает в совершенно другую перегрузку и т.п. Идея хотя и проста-гибка, но по сложности масштабируется плохо.
Обязательный вызов build может показаться неудобным, а ещё его очень легко забыть (периодически сам забываю после перерыва работы с движком) и в критичных местах нужно расставлять null-проверки. Логично поискать способы от него избавиться.
Да, существует способ внедрения через статику - ambient context, доступный в конструкторе. Тогда не нужно пробрасывать зависимости явно, вызывая метод сборки. После создания объекта зависимости уже есть, что предотвращает множество проблем и null-угрозу. В D статические переменные отдельные для каждого потока (TLS), через scope можно сделать RAII для смены-возврата контекста, немного уменьшая недостатки статики. Но это следом усложняет конструкторы, нужно управлять сборкой теперь уже в них, пробрасывая флаги. Неловкое движение и проблема возвращается: зависимостей нет, когда они нужны, либо сборка запускается многократно. Возможны некие гибридные варианты, но их нужно изучать внимательнее.
Внедрение автоматически выстраивает архитектуру в дерево компонентов, где каждый компонент собирает детей, пробрасывая им зависимости. Владелец может управлять своими собранными - рисование, перемещение и прочее, а также делегировать это кому-то другому. Это рождает паттерн Компоновщик. Максимально удобный для GUI, но, как всегда, у такого дизайна есть некоторые проблемы.
Много багов появляется в инвариантах родитель-дети (родитель, дети - условные понятия, не в смысле инстанцирования). Иногда нужно перемещать детей вместе с родителем, а иногда нет, изменять размеры, прозрачность, управлять событиями в зависимости от того или иного флага. Побочные эффекты добавляются компоновщиками у обоих. Это всё сильно усложняет логику, демонстрируя фатальную проблему композиции - резкое усложнение с непредсказуемыми побочными эффектами, состоянием, поведением.
Сложность превращает систему инвалидации в очень большую проблему. Я долго экспериментировал, но из-за большого количества побочных эффектов удобного решения пока не нашлось. Того, которое бы не давало много багов при изменениях. Победил популярный инженерный приём - наоборот. Да, инвалидацию можно просто выключить, а потом включать, если макет не предполагает сложных побочных эффектов навроде запросов размеров вверх-вниз по дереву.
Часть оверхеда от перерасчёта сцены на каждый кадр можно сгладить на уровне работы компоновщика, как и самого спрайта. Например, проверяя старые и новые размеры, заложив небольшую дельту. Тогда оверхед будет по большей части от сравнений. Но иногда возможен дребезг. И это не сработает с компоновщиками в тех же полярных координатах, где есть тригонометрия. И она, в общем случае, будет вычисляться каждый кадр. Так что пока в процессе поиска и проблематика тесно пересекается со следующей проблемой.
Дерево разбрасывает данные по разным объектам, что противоречит data-oriented design с промахами кешей. Близко у нему и ECS. Проблематично использовать SIMD. Добавление этого всего требует усложнения, причём немалого. Хотя какую-то часть проблем можно решить через дизайн отдельных контролов, просто не делая их иерархическими.
Дерево создаёт проблемы, когда появляются выпадающие списки, диалоги с частичным или полным замораживанием сцены. Часть контролов должны обновляться, часть - нет, часть накрывать другие и блокировать события, причём рисоваться все по-разному.
Дерево усложняет определение точки готовности макета. Изменения пробрасываются в разных направлениях - сверху и снизу с разной скоростью. Это очень большая проблема для GUI. Можно назвать это переходным процессом по аналогии с электроникой: при запуске движке проходит некоторое время до стабилизации макета. Пока использую ровно такой же "электронный" способ - добавление задержки при запуске делегата. Это просто, но часто нестабильно. В общем, как всегда, дерево не только решает часть проблем, но и создаёт много новых. Но оно настолько удобно, что отказаться от него невозможно.
Часть проблем можно убрать через создание двунаправленных связей, разрешив обращение как вверх по дереву к родителю, так и выше. Например, контролы могут добавлять себя в линейную структуру данных у сцены для управления фокусом, вместо сложных обходов дерева, но следом появляется проблема синхронизации двух структур. Двунаправленные связи, которые превращают строгую иерархическую структуру в циклическую, не любят в архитекте, они создают ещё больше сложности, но вводить миллионы коллбэков ещё проблемнее.
Системный анализ ещё различает иерархические структуры со строгими связями - каждый узел подчинён одному родителю, так и со слабыми - нескольким родителей. Движок проверяет поле родителя у спрайта и выбрасывает ошибку, если он уже есть. Но это можно отключить флагом. Между родителями могут быть конфликты, но при разделении ответственности - один перемещает, другой - рисует, они вполне могут сосуществовать. Потенциально опасные, но интересные механики, требующие более внимательного изучения.
Наличие сложных побочных эффектов в дереве, отложенной инициализации намекает на повышение обратной связи от кода. Простой вариант - возврат булевых флагов. Я часто сталкивался с этой проблемой в промышленных GUI. Например, вызывается перемещения или установки размеров и ничего не происходит. Неплохо было бы сразу понимать, сработал ли вызов или же нет:
auto tex = new Tex2d;
tex.width = 100;
if(!tex.width(100)){
//размер не поменялся т.к. там уже такой же
}
Ну а поскольку любая идея создаёт проблему, то эта способна затереть ошибку, когда флаг ошибки из нижних слоёв превращается в сигнал отсутствия действий в верхних. Например, if(!trySet(prop)) return. Но если флаг просто сигнализирует об установки одного из множества свойств, то он начинает управлять потоком выполнения.
Ещё это не даст сделать Fluent interface, как и вернуть что-то из функции навроде удобного auto root = addCreate(new HBox). Добавление же флага ошибки как out или ref-параметр неудобно. Из-за опасности побочных эффектов булев возврат используется местами, тестируется небольшая часть API.
GUI-тулкиты также разнятся и по способам кастомизации, степени разделения отображения от поведения. От расслоения контрола по MVC пока отказался, опыт показал трудоёмкость такой настройки. В путаной GUI-логике часто сложно понять, где отображение, где - поведение, где должно быть состояние. Они и так взаимодействуют сложно, разброс по коду ещё больше это усиливает, создавая бесконечные баги. Часть того же самого можно достичь наследованием с делегатами.
В одиночном наследовании универсальная логика сдвигается к базовым классам, а вернее к базовому контролу. Это позволяет сделать любой элемент, например, контейнер, интерактивным, все основные эффекты есть у родителя. Максимально удобно, но нужно уметь отключать неиспользуемое.
Логично задействовать null-состояние указателя. Null даёт три алгоритма сборки внутренностей контрола: отключено, включено, включено\отключено частично для не null-поля, не вводя дополнительный флаг. Хотя риск нарваться на null повышается, но есть свои преимущества. D тоже старается помочь: есть флаг (-check=nullderef), включающий проверки указателей на null, появился экспериментальный DFA (Data Flow Analysis).
Дизайн слушателей событий переделывался несколько раз. Опыт показал неудобство одиночных слушателей: нужно постоянно проверять, есть ли там уже слушатель, сохранять его и вызывать, чтобы не перезатереть, что долго и чревато ошибками. Здесь удобнее массивы.
auto btn = new Button;
foreach(i; 0..5)(pi){
btn.onPointerPress ~= (ref e) => writeln("Press :", pi);
}(i); //IIFE-аналог для корректного захвата переменной по значению в цикле
addCreate(btn);
С другой стороны, динамические массивы, хотя бы и не большие, в полях контролов создают проблемы переключения на другой вид памяти, усложняют отписки, создают шанс дублирования слушателей, увеличивают размер класса. Возможно, есть смысл заменить их на структуру с перегруженным оператором. Либо просто уменьшить их количество: движок использует массивы слушателей для наиболее используемых событий, для остальных обычное поле.
Изначально возвращал булев флаг из слушателей, но постоянно путался, true должно быть или false, а также страдал от неудобств обязательного его возврата в сложных ветках условий. В итоге отказался от возврата результата с заменой события-значения на ref-ссылку.
Тонкий момент в возможности отключения срабатывания слушателей. Это очень большая проблема в методах сброса и похожих, где значения устанавливаются из кода. Нужно выставлять флаг отключения или занулять слушатель, потом устанавливать значение и снова взводить флаг. Простой вариант - передать флаг при установке значения, UFCS в D здесь особенно удобно:
auto scroll = new HScroll;
addCreate(scroll);
scroll.onValue ~= (v) => v.writeln;
scroll.value = 1; //тут слушатель выше сработает
scroll.value(1, isTrigger : false); //а тут нет
GUI-тулкиты стремятся к разделению оформления от кода, к чему близка проблема LoB (Locality of Behaviour). Архитектурно - разделение красиво. Однако если код в одном месте, а оформление где-то далеко и нельзя сразу понять, как где оно работает, то вся красота сразу же испаряется.
Возможность настроить каждую мелочь в CSS полезна, но создать новую тему очень сложно, а поддерживать всё богатство синтаксиса накладно. Пробую темы с небольшим количеством настроек, но появляется проблема подстройки под специфические требования контролов. При этом перенастраивать тему иногда неудобно, что намекает на какой-то функционал отдельных стилей.
auto btn = new Button;
btn.style = new GStyle(lineColor : RGBA.red); //установка стиля напрямую. Это указатель, чтобы не хранить структуру стиля в классе. Минус: шанс нарваться на null, прямой проброс разрешает изменить стиль кому угодно
btn.styleId = ThemeStyle.danger; //или из таблицы стилей темы
btn.addStyle(ControlStyle.hover, GStyle(fillColor : RGBA.blue, isFill : true)); //или из таблицы стилей контрола. Но оверхед на две проверки хеш-мап. Быть может есть смысл отключать флагами.
enum styleName = "mystyle";
theme.addStyle(styleName, GStyle(fillColor: RGBA.yellow, isFill : true)); //регистрация глобального стиля
btn.styleId = styleName;
Да, здесь снова проблемы. Конфликт стиля с состоянием контрола, где есть флаг фоновой заливки. Можно ли изменять стиль, добавляя заливку в зависимости от флага, либо стиль всегда с заливкой и трогать нельзя. Оверхед от проверок стилей, большая структура стиля для универсальности, разные стили для разных случаев и т.п. Оптимизации ещё больше усложнят такую систему, что для цели рисования уже переусложнение ради сомнительной выгоды. Вероятно, вынос таких стилей в файл конфига темы мог бы дать некий гибрид между темой и CSS.
D позволяет выстроить более интересную систему управления иммутабельностью. Мутирующие методы отслеживается и запрещаются в иммутабельном контексте. Часть логики, те же геттеры работают одинаково везде. Можно спроектировать класс, объект которого способен быть как мутабельным, так и иммутабельным.
enum keyName = "key1";
auto mutConfig = new AAStrConfig([keyName : "10"]);
mutConfig.setPositiveInt(keyName, 20);
assert(mutConfig.getPositiveInt(keyName) == 20);
immutable immutConfig = new AAStrConfig([keyName : "10"]); //запускается иммутабельный конструктор, или же new immutable AAStrConfig
assert(immutConfig.getPositiveInt(keyName) == 10); //так снова можно
//immutConfig.setInt(keyName, 20); //а так уже нельзя
Да, с состоянием немного сложнее, оно тоже должно быть иммутабельным. Если контрол с конфигом запускает другой поток, то у него конфиг мутабельный и корректный путь - скопировать состояние, что некие накладные расходы в зависимости от его размера, хотя можно копировать частично или вообще не копировать, если гонки нет (т.н. effectively immutable\read-only access, когда синхронизации нет, но мутирующие методы банально не вызываются), обманув компилятор кастом.
Так что не везде подходит, но если подходит, то можно избежать неудобств. Например, иногда предпочитают полностью иммутабельные объекты, особенно в высокоуровневых языках. У них много преимуществ, но если они чаще используются как изменяемые, лишь изредка попадая в иммутабельный контекст, то постоянно и многократно пересоздаются, бесполезно нагружая сборщик (ну разве что срабатывают какие-нибудь оптимизации на стек, но всегда ли). Если состояние простое, то таким образом можно избавиться от этого оверхеда, добавив иммутабельные\константные конструкторы и пометив методы const\immutable\inout.
Надёжность (R).
Упрощённо можно разделить отказы на два вида - внутренние и внешние из библиотек. Частный случай второго - невозможность использовать какие-то библиотеки вообще.
Одно из наиболее уязвимых мест движка - петля событий, где неплохо бы запретить появление исключений, пометив nothrow. Но неудобно: если вынести перехват на уровень вверх и там убирать проблемный спрайт из дерева, то логика примерно сохраняется. И не нужно страдать из-за ограничений nothrow, плодя блоки перехвата при частом наследовании отрисовки и обновления. Так можно расставить несколько предохранителей от наиболее вероятных падений.
Более низкоуровневые обёртки над SDL вполне могут быть nothrow. На некоторых ошибках можно выбрасывать Error, nothrow запрещает только исключения т.е. восстановимые ошибки. Несомненный плюс Error - возможность залогировать или записать в краш-файл где-то наверху, если логгер не инициализирован. Обойдясь, таким образом, в SDL-обёртках вообще без логирования.
Неперехваченную ошибку нужно залогировать, однако логгер может быть ещё неинициализирован. Когда-то давно, в одном из приложений я просчитался, завязав краш-файл только на консоль и файловую систему. При запуске из шелла путь сбивался и краш-файл не писался, ошибка терялась. Есть смысл подстраховаться записью в системный журнал, syslog на Linux.
Внедрение позволяет легко подменить используемые компонентом зависимости на моки и стабы для тестирования. При этом стаб близок по смыслу к поведенческому паттерну Null Object, где своя заглушка удобна для всех (или некоторых) внешних зависимостей. Так компонент легко собрать без полного цикла построения дерева зависимостей. Но поскольку движок постоянно переделывается, то заглушки сейчас есть лишь у наиболее стабильных зависимостей, иначе нужно переделывать и их тоже. В будущем можно подумать о добавлении нуль-объектам и проверок ожидания, получая уже мок.
Интеграционное тестирование также возможно, SDL способна запускаться в headless режиме, можно сделать скриншот сцены, сравнивая его с эталонным. Но для реализации более продвинутых алгоритмов навроде SSIM нужно переписать цветопроцессинг в движке. На многомерных массивах он проще дебажится, но медленный.
В некоторых контролах, например, вкладки с табами тоже можно расставить предохранители, выключающие вкладку на исключении. Так контрол частично сохранит работоспособность.
Производительность (P).
Самая большая проблема здесь - графика требует пересборок и экспериментов, ей критично не только время выполнения, но и время компиляции. Ресурсы нужно делить между ними, соблюдая баланс, при этом оверхед движка просуммируется с таковым у приложения. Потенциальная компиляция прямо на IoT железе выдвигает и требования по снижению потребления памяти при сборке.
Всё это сильно сужает манёвр по метапрограммированию и шаблонам, кои удобны на этапе прототипировании. DMD - очень быстрый компилятор (но итоговую сборку лучше соптимизирует LDC), но чтобы не сильно отставать от тулкитов с горячей перезагрузкой (hot reload), компилировать нужно как можно быстрее. У D есть встроенный профайлер компиляции, но в сложных системах мелкие оверхеды суммируются. Много вызовов форматирования, nullable-обёртки, шаблоны типов в аллокаторах, умных указателях и прочей сквозной логике совсем небесплатны. Стандартная библиотека тоже требует аккуратности. Например, был случай резкого падения времени компиляции из-за std.regex.ctRegex.
Местами пришлось заменить метопрограммирование на обычный кодоген скриптом и копипаст, исключить любые шаблоны в сквозной логике, убрать даже кастомные исключения с удобным миксином basicExceptionCtors. Появляется конфликт с логированием: чем сложнее система, тем лучше должно быть логирование. Но тем лучше логирования, чем выше его накладные расходы на время компиляции. Пришлось убрать часть трассировки под условную компиляцию.
Также движок изначально использовал double для повышения точности вычислений, что актуально для математики симуляторов. Но позже перешёл на float, как и у других популярных движков. Хотя в 3D может быть проблема для больших миров, double скорее всего добавится точечно. Как и в некоторых расчётах оно используется, например, в грубых алгоритмах определённого интеграла - метод трапеций, Симпсона, float даёт большую погрешность. Ну либо вводить компенсационное суммирование Кэхэна и прочее.
Обойти дерево можно рекурсивно с шансом переполнения стека, но на небольших сценах этого вполне хватает. Систему событий неплохо бы упростить, отказавшись от обратной фазы всплытия, хотя и пожертвовав некоторым удобством. Как и любых очередей, сортировок, аллокаций объектов или памяти для событий. Но здесь ещё много вопросов: часть задач без всплытия создают проблемы, пространственные структуры данных для быстрых координатных запросов нужно синхронизировать со сложным деревом и т.д. и т.п.
В целом, по рантайму движок на этой стадии прототипа ещё не оптимизирован из-за постоянных изменений. Сложно как-то соптимизировать то, что ещё не готово. Нет Арена-аллокатора, столь любимого в движках. Система событий, инвалидации сцены в процессе экспериментов, как и прочее. Но здесь можно оптимизировать бесконечно. Пока не надоест.
Поддерживаемость (S).
В первую очередь логично понизить проблемы от зависимостей, сократив количество используемых движком библиотек. Меньше зависимостей - проще поддержка. Движок не использует никаких сторонних библиотек на D, а компиляцией привязан только к SDL, загружая всё остальное динамически.
Но SDL - обширная библиотека и чем больше завязывается на неё движок, тем сложнее в будущем будет это поменять. Так, позже я переехал с SDL_image на libpng и libjpeg, с SDL_ttf на FreeType, как и с SDL_Mixer на PortAudio для аудиобуфера, распределяя риски. Зависимости стали более низкоуровневыми, удобнее графическому движку. Кроме того, в SDL3 сильно поменялась аудиосистема и переделывать её каждый раз занятие очень малоприятное, учитывая малые возможности. Поэтому SDL_Mixer пошёл под нож ещё и по этой причине.
И если SDL - первая критичная зависимость, то Cairo - вторая. Привязка к векторной библиотеке не выглядит критичной, движок проверяет доступность векторной графики (флаг выставляется после успешной загрузки библиотеки). Но выставление глобального флага возможностей требует предзагрузки библиотеки, когда как она может быть ленивая. Этот флаг легко не проверить, нужно дублировать проверки где-то глубже внутри движка, чтобы не было сегфолта на null. Получается обычная graceful degradation, но её ещё нужно совершенствовать.
А если отключить всё, кроме SDL (libSDL3.so). Можно пробовать откатиться к простым формам. Местами не очень красиво и лучше переключиться на тему с прямоугольными формами.

Но работает, что может пригодиться при авариях, тестировании на других платформах, бед с библиотеками. Не все контролы можно нормально отрисовать без вектора на упрощённом 2D, но часть - вполне. Такую деградацию можно значительно улучшить, она может выглядеть в разы приятнее. Теоретически, можно отвязаться и от SDL, но это время и усложнение движка на унификацию между разными бэкендами. Но возможно.
Графика тесно связана с математикой, математическая библиотека тоже удобнее своя. Напомню, что в D дробные числа инициализируются NaN, а не 0. Это актуально при портировании кода, где операции с матрицами предполагают 0 в массиве. Иногда NaN рандомно внедряется в длинную цепочку вызовов, создавая местами трудноуловимый класс багов. NaN необязательно будет в её результате, что выявилось бы сразу. Могут отработать различные условные ветви, сравнивая его с числом, с самим собой (как известно, NaN != NaN) и не добавить малый корректирующий коэффициент к расчёту. Вычисление выглядит корректным, но таковым не является. Появление DFA в компиляторе немного смягчает эту проблему.
Система самодиагностики (можно свести к валидации состояний) пока самая простая. Классический асинхронный парсинг лог-файла для напоминания об ошибках в логе, проверка совпадения ключей в конфиге и в коде, а также ключей локализации. Служба-перехватчик ошибок из логирования. Тут ещё нужно подумать, отключать ли их при запуске без файловой системы, либо просто сделать настраиваемыми.
На этом пока всё, статья и так получилась объёмной. Из-за D движок получился гибридным. Местами похожим на высокоуровневые GUI-тулкиты, так и на низкоуровневые системные, где SDL и любые библиотеки можно использовать напрямую, обходя все абстракции. ImportC делает это особенно удобным. Либо же из обёрток движка можно просто выдрать сырой указатель.
Так что эксперимент вышел хотя и трудоёмким, но и очень интересным, да и успешным. Можно изменять как угодно, подстраивая под любые требования, добавлять, удалять любой функционал, оптимизировать и улучшать бесконечно - масса возможностей. Получил тонны полезного опыта, что оправдало все потери времени. Без дела эти несколько лет не сидел и теперь у меня есть собственный GUI, а вернее графический движок.
На этом месте можно было бы и остановиться: GUI есть, а 2D достаточно для самых разных интересных симуляций. Процедурная генерация упрощается в разы для 2D. Задача фактически выполнена, дальше - совершенствование, оптимизация, разрешение архитектурных противоречий, неспешная эволюция. Но можно ли сделать ещё лучше... вопрос интересный.
Новый эволюционный этап стал возможен после появления в SDL3 абстракции конвейера - SDL GPU. Переход в 3D открывает новые возможности, но нужно натянуть его на 2D, при этом не испортив большую часть достижений и удобств прежней архитектуры. Исследую это направление.