RTLD — низкоуровневый рантайм для D

Я начал разработку dlib в далеком 2011 году, когда писал на старом добром OpenGL 1.x под 32-битным Linux. Тогда я и не помышлял об альтернативных архитектурах и кросс-компиляции, мне даже в голову не приходило, что у D когда-нибудь появится поддержка такого количества разнообразных платформ. Но к началу 20-х уже стало очевидно, что грядет эпоха ARM, и старичку x86 придется потесниться, а сегодня AArch64 на устройствах мощнее телефона — уже не причуда и экзотика, а обыденная реальность. Стоит это принять и начать уже думать об ARM не как о мобильной, а как о второй десктопной архитектуре. Это необычное положение дел, потому что на десктопе x86 всегда царил монопольно. Но я вообще-то люблю изучать все новое, и для меня как графического разработчика освоение еще одной процессорной архитектуры — интересный вызов. Надо ли прямо сейчас всем кидаться портировать весь софт на ARM? Я думаю, нет, но какие-то базовые штуки, полезные библиотеки, фреймворки — желательно. Вот и я решил не оставаться в стороне.

Хорошая новость состоит в том, что D, в лице компилятора LDC, отлично поддерживает ARM. LDC — это неявный кросс-компилятор, он умеет генерировать код почти под все платформы, поддерживаемые LLVM, и это можно делать на любой хост-системе, не пересобирая тулчейн. ARM существует в разных аппаратно-программных вариантах, я под ним буду подразумевать aarch64-linux-gnu — то есть, платформу с процессором AArch64, ядром Linux и окружением GNU. Проще говоря, установив LDC, вы можете на x86 собирать приложения под десктоп или сервер на базе AArch64, о чем я уже писал в предыдущем посте.

Плохая новость состоит в том, что для компиляции под нестандартные таргеты нужны соответствующие версии Phobos и druntime. Я боюсь представить, что это за квест — собрать их, и не имею никакого понятия о статусе Phobos для AArch64. Мне это даже не очень интересно, потому что Phobos я считаю не самой удачной стандартной библиотекой, я ее почти не использую — она сильно перегружена, половину можно было бы оттуда вынести в дополнительные DUB-пакеты, многие модули откровенно устарели, некоторые функции работают странно, а шаблоны порой не компилируются. Для меня уже привычен хардкорный путь — игнорировать Phobos и писать все свое. К тому же, под маломощными одноплатниками с маленькой памятью вам все равно GC не друг, и лучше все делать на @nogc-компонентах.

Все эти измышления подвели меня к идее создания новой библиотеки общего назначения, более низкоуровневой, чем dlib. Я ее назвал RTLD — Real-Time Library for D. Она основана на модулях, которые я изначально писал для dlib2/dcore, а также на прямых портах из dlib 1.x. В отличие от dlib, это будет настоящая замена Phobos. RTLD предоставляет самую базовую функциональность, работая поверх libc, WinAPI, POSIX, системных вызовов Linux, а также динамически загружаемых функций — X11 и OpenGL ES. По сути, это библиотека для тех, кто хочет писать на D как на C, не используя ничего лишнего. Однако я намеренно не стал делать ее BetterC, в отличие от dcore. Взвесив все «за» и «против», я решил, что от BetterC на десктопе толку мало — от классов никуда не деться, они все равно потребуются на определенном уровне абстракции, поэтому зачем изначально себя их лишать?

https://github.com/gecko0307/rtld

На данный момент RTLD поддерживает Windows и Linux, компилируется под x86_64 и AArch64. Некоторые ее модули платформонезависимы, и их можно использовать в системной разработке, под bare metal.

Текущий список фич:

  • rtld.core — ключевые функции: работа с динамической памятью, загрузчик библиотек, потоки, мьютексы, создание окна
  • rtld.gl— биндинг к OpenGL ES 2/3 и создание контекста (поддерживаются Windows и Linux/X11)
  • rtld.libc — функции библиотеки C
  • rtld.math — элементарные математические функции
  • rtld.random — генератор псевдослучайных чисел
  • rtld.sys.windows — функции WinAPI
  • rtld.sys.posix — функции POSIX
  • rtld.sys.linux — функции Linux
  • rtld.text — текстовые кодировки
  • rtld.time — функции даты-времени, наносекундный таймер.

Закономерен теперь вопрос архитектуры будущей dlib2. Я склоняюсь к мысли отказаться от dcore в пользу RTLD — это будет базовый бэкенд dlib2, реализующий львиную долю функциональности, которая не зависит от GC. К сожалению, полный отказ от GC в dlib невозможен по ряду причин, но я надеюсь сильно сократить его присутствие.

Кросс-компиляция «Hello, World» под ARM

Я всю жизнь разрабатываю под x86 (за исключением экспериментов с MIPS-I/PSX, но это больше из исторического интереса), и мне всегда хотелось написать что-нибудь на D под вторую главную архитектуру современности — AArch64. Недавно я досконально изучил, как это правильно делать, причем самым, на мой вкус, безболезненным способом, который не требует WSL, Docker и монструозных тулчейнов — понадобится только обычный свежий LDC плюс несколько дополнительных бинарей, которые легко скачать в готовом виде.

Итак, программа (src/main.d):

module main;

extern(C) nothrow @nogc
{
    int printf(const(char)* fmt, ...);
}

extern(C) int main(int argc, char** argv) nothrow @nogc
{
    printf("Hello, World!\n");
    return 0;
}

Для начала нужно убедиться, что ваша версия LDC собрана с поддержкой aarch64: ldc2 --version.

Важный вопрос: в каком режиме компилировать — BetterC или нет? По моему мнению, на большинстве популярных платформ BetterC дает ноль преимуществ, отнимая важные фичи языка вроде классов. Он хорош для микроконтроллеров, для WASM, для всякой экзотики, но точно не для привычного GNU userspace, который наличествует на любой железке мощнее калькулятора. Так что я предлагаю сразу идти нормальным путем и заменить те функции druntime, которые вам нужны, самописными реализациями. Для простейшего примера, впрочем, это не требуется.

Моя команда сборки выглядит следующим образом:

ldc2 -Isrc -i --vgc
    -defaultlib= ^
    -debuglib= ^
    -mtriple=aarch64-linux-gnu ^
    -linker=lld ^
    -Xcc=--sysroot=sysroot/aarch64-linux-gnu/libc ^
    -Xcc=--gcc-toolchain=sysroot ^
    -Xcc=-unwindlib=none ^
    src/main.d ^
    -of=test

Она предполагает, что в папке sysroot находится минимальное системное окружение, необходимое для компиляции linux-приложений. Я его взял отсюда: https://github.com/ClickHouse/sysroot. Заберите папку linux-aarch64 из репозитория со всем содержимым и переименуйте в sysroot. Это же окружение понадобится для тестирования.

При желании можно оформить то же самое для DUB:

{
    "name": "test",

    "targetType": "executable",
    "targetName": "test",

    "sourcePaths": ["src"],
    "importPaths": ["src"],

    "dflags-ldc": [
        "--vgc",
        "-defaultlib=",
        "-debuglib=",
        "-mtriple=aarch64-linux-gnu",
        "-linker=lld",
        "-Xcc=--sysroot=sysroot/aarch64-linux-gnu/libc",
        "-Xcc=--gcc-toolchain=sysroot",
        "-Xcc=-unwindlib=none"
    ],
    "postBuildCommands-windows": [
        "copy /Y test.exe C:\\Users\\gecko\\Documents\\vbshared\\arm\\test",
        "del test.exe"
    ]
}

Последнее нужно под Windows, чтобы на выходе получился бинарник без расширения «.exe».

Если все хорошо, то вы получите файл test, который можно сразу проверить:

file ./test

Команда выведет что-то вроде

./test: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for
GNU/Linux 3.7.0, with debug_info, not stripped

Если есть ARM-компьютер, то можно запустить приложение на нем (я тестировал на Khadas VIM2), но для тестов гораздо практичнее настроить виртуальное окружение. Если у вас Linux, то рекомендую https://github.com/multiarch/qemu-user-static — нужно скачать qemu-aarch64-static на странице релизов. Это эмулятор пользовательского режима QEMU, который позволяет запускать бинарники AArch64 непосредственно на хост-системе.

Использую я его так:

./qemu-aarch64-static -L ./sysroot/aarch64-linux-gnu/libc/ ./test

Параметр -L передает эмулятору путь к корневой папке окружения, там должны быть динамический линкер /lib/ld-linux-aarch64.so.1 и папка lib64 с библиотеками C/POSIX — проверьте. Важно убедиться, что вместо симлинков не лежат пустые файлы, под Windows часто так получается.

Для Windows я, к сожалению, не нашел эмулятор юзерспейса linux-aarch64 (может, его и нет вовсе). Ручной запуск ARM-машины QEMU — это жуткий геморрой. Самый беспроблемный способ, который не заставляет постоянно запускать и останавливать тяжелые сессии эмуляции (равно как и вводить много команд вручную) — тупо установить обычный десктопный Linux в VirtualBox и там гонять qemu-aarch64-static, не выключая систему. Это немного костыльно, но жить можно, особенно если держать весь проект сразу в shared-папке хоста. Мне лично это удобно тем, что я и так использую Linux на виртуалке для других тестов.

Рендеринг волос в Dagon 2

Добавил анизотропную BRDF Kajiya-Kay для рендеринга волос:

Текстура волос может иметь альфа-канал, но вместо альфа-смешивания используется упрощенная прозрачность на основе дизеринга Байера.

dlib 1.7

В рамках Dagon 2.0 скопилось множество улучшений для dlib, и данный релиз подытоживает эту работу.

Добавлен модуль dlib.math.base — замена std.math, которая заменяет некоторые часто используемые функции быстрыми интринсиками LLVM при компиляции LDC (подробнее об этом тут). Ускорены следующие элементарные функции: fabs, sqrt, sin, cos, tan, ceil, floor, round, trunc, rint, nearbyint, pow, exp, exp2, log, log2, log10, fmax, fmin, fma, copysign. В среднем они вдвое быстрее, чем в Phobos. Единственный нюанс, который следует помнить — лучше не импортировать оба модуля одновременно, иначе будет конфликт, и вам придется использовать квалифицированные имена при вызовах.

Добавлена встроенная SIMD-оптимизация для перемножения матриц 4×4 (Matrix4x4f). Это ускоряет оператор в 3-4 раза. В связи с этим удален модуль dlib.math.sse как устаревший, неподдерживаемый и, как выяснилось, нерабочий под x86_64.

Хэш-таблица FlatHashMap из Dagon 2.0 добавлена в библиотеку как dlib.container.hashmap. Реализация xxHash64, соответственно, — как dlib.hash.xxhash64 в новом пакете dlib.hash. Напомню, что это очень быстрая реализация ассоциативных массивов без использования сборщика мусора, которая в десятки раз эффективнее, чем dlib.container.dict и в 1,5-2 раза — чем встроенные ассоциативные массивы D.

Полностью переработан dlib.random — пакет теперь работает на основе перемешанного конгруэнтного генератора (PCG), реализованного для 32- и 64-битных чисел. Это современный эффективный генератор псевдослучайных чисел, который превосходит как Вихрь Мерсенна (std.random), так и линейный конгруэнтный генератор (rand в стандартной библиотеке C). Добавлены новые функции choice (случайный выбор из заданного множества вариантов) and rollDice (случайное целое число от 0 до заданного порога). Функция seed, генерирующая зерно для инициализации генератора из системной энтропии, теперь использует функцию смешивания fmix64 из MurmurHash3 вместо mix Боба Дженкинса.

Не используйте std.random!

Стандартная библиотека использует классический Вихрь Мерсенна (MT19937). Недавно я, вдохновившись эффективностью PCG для шейдерных вычислений, добавил соответствующий модуль в dlib, заменив им rand в dlib.random. Оказалось, что моя функция random для выборки равномерного распределения в диапазоне 0..1 аж в 4 раза быстрее, чем std.random.uniform!

Бенчмарк для 10000000 вызовов:

std.random.uniform: 77 ms, 38 μs, and 3 hnsecs total
dlib.random.random: 19 ms, 599 μs, and 3 hnsecs total

Стандартное отклонение у обеих функций примерно одинаковое:

std.random.uniform: 0.544σ
dlib.random.random: 0.413σ

Статья по теории рендеринга в Dagon

Наконец-то дошли руки написать теоретическую статью о модели освещения, которую я использую в движке, со всеми формулами и ссылками на научную литературу. Это GGX/Trowbridge-Reitz, на которую опирается освещение в Unreal Engine 4 и многих других движках — физически обоснованная модель зеркально отраженного света для изотропных шероховатых поверхностей. Ее многие сейчас используют, одни и те же уравнения можно встретить в самых разных движках, но мало кто задумывается, откуда они взялись.

Поскольку в тексте много математики, я оформил статью в виде PDF — можно скачать файл или читать при помощи встроенного в браузер просмотрщика.

Не используйте ассоциативные массивы!

Шучу, конечно. Но специализированная хэш-таблица действительно может быть заметно быстрее встроенных ассоциативных массивов D. На днях начал писать менеджер ассетов для Dagon 2.0 и решил заодно заменить dlib.container.dict для хранения ссылок на ассеты чем-то поэффективнее. Результатом стал класс FlatHashMap — таблица, которая хранит все элементы в одном непрерывном блоке памяти, что значительно улучшает производительность за счет кэш-дружественности. Кроме того, такая таблица работает в среднем за постоянное время, что выгоднейшим образом отличает ее от префиксного дерева (на котором основан dlib.container.dict) — скорость префиксного дерева пропорциональна длине ключей, а flat hash map работает с хэшами фиксированного размера.

В качестве хэш-функции я взял быстрый xxHash64, который можно использовать в реальном времени. Моя версия в Dagon — это частичный D-порт xxhash-clean, минимальной референсной реализации xxHash. Результаты бенчмарка следующие: вставки у FlatHashMap получились аж в 36 раз быстрее, чем у dlib.container.dict, и в 1,7 раз быстрее, чем у ассоциативных массивов. Поиск — в 15 раз быстрее, чем у dlib.container.dict, и в 1,6 раз быстрее, чем у ассоциативных массивов. Эффективен и opApply — это обычный линейный обход буфера безо всякой рекурсии и сложной логики.

SMAA против всех

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

Сегодня многие движки используют TAA, но в Dagon я его пока добавлять не хочу по ряду причин. Все темпоральные техники основаны на идее накопления случайных сэмплов в условиях кадровой когерентности — это нормально работает, только когда камера неподвижна или движется очень медленно. При резких движениях камеры историю приходится сбрасывать, и визуально это проявляется в том, что эффект то ослабевает, то усиливается. Для сглаживания, имхо, это выглядит слишком неестественно — и так уже приходится терпеть темпоральные артефакты всяких SSLR и т.п. Плюс TAA сглаживает всю картинку, а не только края — а это то, от чего индустрия, по идее, пыталась уйти. В общем, даже качественные реализации TAA, например в Unreal, выглядят на мой вкус так себе.

Окончательным компромиссом между стареньким FXAA и современным, но не очень привлекательным TAA стал алгоритм SMAA — субпиксельное морфологическое сглаживание, разработанное в начале 2010-х по заказу AMD. Работает на современных видеокартах очень быстро — на глаз я не заметил никакого замедления. SMAA сглаживает только края, так как алгоритм основан на распознавании краев. Минусом по сравнению с FXAA является повышенное потребление памяти — добавляются два дополнительных буфера.

Чтобы включить SMAA в Dagon 2.0, нужно указать в render.conf

antialiasing: "SMAA";

Либо просто включить высокий профиль качества (profile: 1). FXAA тоже никуда не девается и включен по умолчанию для низкого профиля качества (profile: 0).

antialiasing: "FXAA";

Особенности декодирования PNG

На днях провел самую нетривиальную сессию отладки за всю историю Dagon: по-тихому сломалась загрузка одной из текстур в сцене с лапшичной — 16-битный grayscale PNG, карта высот под parallax mapping. Я уже сталкивался с багом обработки 16-битных изображений в SDL_Image, но пофиксил эту проблему только для RGB — SDL_Image выдает формат SDL_PIXELFORMAT_RGB48, можно перехватывать такие кейсы и конвертировать в 8 бит на канал вручную вместо вызова SDL_ConvertSurface. Но в случае с grayscale чудит уже сам декодер, выдавая почему-то формат SDL_PIXELFORMAT_INDEX8, тогда как grayscale — это не индексированный режим, а отдельный пиксельный формат, поддерживающий разную битовую глубину. Я так и не понял, что с этим делать, и решил для загрузки PNG адаптировать мой старый декодер из dlib.

Сказано — сделано: я создал dagon.resource.png, заменил SuperImage на TextureBuffer и выпилил поддержку APNG, которая в играх все равно не нужна. Но после этого внезапно сломалась загрузка сцены Sponza — для всех текстур декодер просто выдавал мусор в чанках. Я долго ломал голову, искал ошибку в алгоритме разбора, пока не додадался на всякий случай проверить одну из текстур hex-редактором. Оказалось, что это вообще не PNG, а JPEG! SDL_Image такие картинки переваривал без проблем, поэтому я даже не замечал. Поэтому, чтобы вернуть старое поведение, пришлось городить детектор формата на основе сигнатур. В итоге, впрочем, загрузчик текстур стал работать даже чуть быстрее, чем прежний вариант.