В предыдущей статье был представлен Neptune — слой прокидывания протокола Direct3D для VirtIO. Neptune позволял сериализовать вызовы API Direct3D через границу гипервизора, благодаря чему Wine-игры на гостевой Linux-системе с Linux-хостом запускались быстрее, чем при использовании DXVK непосредственно в госте. Сама по себе эта возможность не выглядела впечатляющей, но она заложила основу для главной цели — современной графической акселерации для гостевых систем Windows. Эта цель достигнута: новый драйвер Windows под названием Triton в связке с Neptune обеспечивает полноценную поддержку DirectX 11 в виртуальных машинах QEMU.

Скриншот игры, запущенной на QEMU на хосте macOS
Crash Bandicoot Trilogy (x64) на Windows 11 ARM64, виртуализированной на macOS через QEMU

Что такое Triton

Может возникнуть вопрос: если Neptune умеет сериализовать вызовы Direct3D API, а Windows использует Direct3D, то разве задача уже не решена? Если Direct3D работает в Wine, то должен работать и в Windows — ведь что такое Wine, если не эмулятор Windows? Короткий ответ — отчасти да. Драйверы Neptune Mesa собирают d3d11.dll и dxgi.dll, полностью реализующие набор API Direct3D, и если просто положить эти файлы рядом с исполняемым файлом игры, она загрузит их вместо системных драйверов Windows — так действительно можно запустить некоторые игры. Именно такой подход использовался в предыдущих попытках, где применялась цепочка DXVK→Vulkan→Venus для локального запуска Direct3D внутри приложения. У такого подхода есть несколько недостатков. Во-первых и главное — невозможно добиться хорошей производительности, потому что композитор окон (DWM) «видит» кадр как обычное изображение и вынужден использовать CPU-блиттинг для копирования буфера GPU в нужное место окна. Для полноэкранных приложений можно применить трюки с прямым сканаутом, но плавного рабочего стола так не получить. Во-вторых, поскольку d3d11.dll и dxgi.dll являются ключевыми компонентами Windows, заменить системные файлы напрямую и ожидать, что система продолжит нормально работать, не получится. Даже если это удастся, многие игры с анти-читами, которые специально детектируют такие модификации, окажутся недоступны. Именно поэтому загрузку DLL можно организовать только для отдельных приложений (с разной степенью совместимости). Отсюда и последняя проблема: копировать файлы в каждое приложение, для которого нужна графическая акселерация, — далеко не самый удобный вариант для пользователя. Правильный подход — не реализовывать API DirectX, а реализовывать DDI (Device Driver Interface).

DDI

В Windows приложение взаимодействует с системными библиотеками Direct3D и DXGI. Библиотека d3d11.dll (как и её более старые версии) выполняет сложную работу по отслеживанию состояния и отправляет более «очищенный» поток команд драйверу пользовательского режима (UMD), который реализует DDI. Приложение также обращается к dxgi.dll для инициализации графических адаптеров, настройки swapchain и так далее. UMD в свою очередь через DXGI взаимодействует с драйвером режима ядра (KMD). KMD реализуется производителем графики (в данном случае — командой UTM) для управления реальным (или, в этом случае, виртуальным) оборудованием. Для Wine был реализован собственный d3d11.dll и dxgi.dll, перехватывающие вызовы API, а для Windows вместо этого нужно реализовать UMD и KMD.

В этом и заключалась задача: реализовать UMD с интерфейсом DDI DirectX, а также настроить приватный интерфейс с KMD, взаимодействующим с устройством VirtIO.

Со второй частью задачи всё оказалось проще — она была уже решена. И anonymix007, и arehnman независимо друг от друга работали над KMD для Venus (Vulkan). Поскольку Vulkan — полностью самостоятельный графический API, ему не нужно реализовывать DDI DirectX, и его UMD похож на подход «замены d3d11.dll» в том смысле, что напрямую взаимодействует с KMD для передачи команд в QEMU. Поскольку Neptune создавался по образцу Venus, высокоуровневые интерфейсы ядра (для DMA, командных буферов и так далее) очень похожи, а интерфейс между UMD и KMD полностью совпадает. В итоге за основу была выбрана ветка anonymix007, поскольку в её реализации было больше работающих функций на стороне KMD.

Осталась самая сложная часть — реализация DDI для DirectX 11. Проектируя новую систему, всегда полезно изучить, как аналогичные задачи решали предшественники. К сожалению, открытых реализаций DDI, на которые можно опереться, почти нет. Драйверы графики для Windows — очень узкая тема, и большинство экспертов работают в считанных компаниях-производителях графического оборудования. Это одна из причин, почему QEMU так долго не мог продвинуться в акселерации Windows-графики.

К счастью, есть два работающих открытых примера для изучения. Первый — Mesa со своим UMD для DirectX 10. Если предыдущая статья не читалась, короткая версия: Mesa реализует OpenGL для Linux. Mesa отслеживает состояние OpenGL и генерирует вызовы Gallium API. UMD DirectX 10 в Mesa — альтернатива OpenGL, генерирующая те же вызовы Gallium API. Затем backend-драйвер Gallium (AMD, Intel, VirGL и другие) преобразует их в вызовы нативного графического API. В upstream-версии Mesa для DirectX 10 поддерживается только программная растеризация, но недавно появилась работа по поддержке VirGL. К сожалению, macOS-версия virglrenderer не поддерживает многие функции, требуемые этим UMD, поэтому это не жизнеспособный путь для акселерации на хостах macOS. Тем не менее интеграция с кодовой базой Mesa дала понятный образец для интеграции Triton.

У VirtualBox есть единственный работающий открытый UMD для DirectX 11. Однако этот драйвер напрямую «позаимствовать» не получится. Драйвер VirtualBox работает так: DDI-вызовы транслируются в промежуточный байткод, который затем на стороне хоста интерпретируется обратно в вызовы API DirectX. Хотя перенести этот эмиттер и интерпретатор байткода в QEMU было бы технически несложно (особенно с помощью AI), от такого решения отказались по нескольким причинам. Во-первых, преобразование DDI в байткод и последующее восстановление из него вызовов DirectX API способно порождать ошибки, ограничивающие совместимость с играми. Судя по форумам, именно из-за этого многие игры не запускаются в VirtualBox. Если в трансляторе отсутствует какая-то функция или есть баг, устранение потребует значительных усилий на поддержку, а зависеть в этом от Oracle не хотелось. Во-вторых, есть несовместимость лицензий: код VirtualBox распространяется по GPLv3, а virglrenderer — по MIT, QEMU — по LGPLv2. Прямая интеграция кода VirtualBox невозможна, но кое-что полезное из него всё же удалось извлечь. Список реализованных и возвращающих ошибку прототипов DDI VirtualBox использовался как минимальный набор требований для рабочей реализации — эта информация отсутствует в документации MSDN, а реализовывать все прототипы подряд было бы избыточно по масштабу. Также оказался полезен алгоритм подписи DXBC из VirtualBox, поскольку Microsoft его нигде не публикует.

Поскольку повторять подход VirtualBox с промежуточным транспортным форматом для DDI-вызовов не хотелось, был найден более удачный путь. Если представить d3d11.dll как компонент, преобразующий вызовы DirectX API в вызовы DDI UMD, то задача UMD в новой схеме — выполнить обратное преобразование, из DDI снова в вызовы DirectX API. Польза в том, что тогда можно использовать уже проверенный и работающий протокол Neptune, не изобретая новый транспорт для сериализации DDI-вызовов. На стороне хоста при этом не требуется никакой дополнительной работы для выполнения этих вызовов. VirtualBox же нужен эмиттер и транспортный слой на госте, а также интерпретатор и диспетчер на хосте — каждый такой шаг добавляет задержку и риск ошибок и несовместимости. В новой схеме на госте всё равно нужны эмиттер и транспорт, но на хосте интерпретатор не требуется, поскольку десериализованные команды Neptune и есть вызовы DirectX 11 API, которые можно диспетчеризовать без дополнительного разбора. Меньше шагов преобразования — меньше шансов на ошибку. Ещё одно преимущество преобразования DDI→API в том, что большинство вызовов DDI в D3D11 имеют прямой аналог в API, поэтому трансформация сводится к сопоставлению API-хендлов с хендлами устройств и иногда поиску различий в перечислениях API↔DDI. Самый большой выигрыш при этом оказался одновременно и самой сложной частью всей истории — это шейдерный код DXBC.

DXBC

DXBC (DirectX Byte Code) — промежуточный код, который генерирует шейдерный компилятор Microsoft (FXC). Это более старый (до-DirectX 12) формат, компилируемый из HLSL — языка шейдеров DirectX. Поскольку Triton выполняет обратное преобразование из DDI в API, ему не нужно дизассемблировать и конвертировать этот шейдерный байткод — большой выигрыш в сложности и совместимости. Тем не менее просто передать байткод хосту без изменений не получится.

Компилятор (FXC) генерирует байткод DXBC вместе с дополнительными метаданными. d3d11.dll ожидает увидеть эти метаданные и использует их. При вызове DDI передаётся только сам байткод. Значит, для корректного «обратного преобразования» в вызов API нужно реконструировать все эти метаданные, анализируя байткод. В итоге байткод передаётся без изменений, но поскольку исходный файл DXContainer недоступен, приходится синтезировать поля, которые ожидает хостовый рендерер DirectX. Эта часть потребовала множества итераций проб и ошибок с помощью AI-ассистента и оказалась самой слабой и подверженной ошибкам частью реализации.

Хостовый рендерер

Итоговая схема работы выглядит так:

  1. Приложение делает вызовы DirectX и DXGI API к системным библиотекам.
  2. Системные библиотеки вызывают Triton через DDI.
  3. Triton DDI восстанавливает исходный DXContainer из необработанного байткода DXBC и делает вызовы DirectX и DXGI API к Neptune.
  4. UMD Neptune сериализует вызовы API и передаёт их через кольцевой буфер, управляемый KMD.
  5. KMD использует интерфейс VirtIO для отправки команд на хост.
  6. QEMU на хосте обрабатывает команду и передаёт вызовы Neptune в virglrenderer.
  7. Хостовый модуль Neptune в virglrenderer десериализует вызовы API и передаёт их в хостовую реализацию DirectX.
  8. Хостовая реализация DirectX рендерит кадр.

Стоит подробнее остановиться на последнем пункте. Когда вызовы DirectX API доходят до хоста, кадр всё ещё нужно отрендерить. При разработке Neptune для Wine на Linux был форкнут DXVK с поддержкой экспорта изображений swapchain как ресурсов DMAbuf. На тот момент было решено реализовать swapchain на стороне хоста, чтобы обойти проблему общих текстур. Внутренне изображения swapchain представлены текстурами, но эти текстуры особенные — хост должен уметь находить их и использовать для показа финального кадра на экране. Библиотека DXGI для Wine пересылает все вызовы API swapchain напрямую на хост, и поэтому хост «знает», какие текстуры будут использоваться как backbuffer. Тогда отдельные команды VirGL можно использовать для сканаута блока текстуры в окно виртуальной машины. Такой подход выигрывает в простоте гостевого драйвера и минимальных изменениях DXVK за счёт усложнения логики swapchain в хостовом процессе virglrenderer.

При разработке Triton выяснилось, что обработка swapchain на стороне хоста была ошибкой. В Windows DXGI — системный компонент, взаимодействующий с UMD. DXGI отвечает за создание backbuffer, тайминг кадров, переключение режимов и так далее. UMD (в большинстве случаев) не даёт DXGI особого обращения, поэтому подход с «обратным преобразованием» вызовов DDI в API плохо сочетается с DXGI. Это значит, что вся логика swapchain, добавленная на стороне хоста, в основном обходится стороной. Композитор рабочего стола (DWM) работает с общими текстурами: один процесс через DXGI рендерит содержимое в собственный backbuffer, который затем передаётся процессу DWM, отрисовывающему рабочий стол, оформление окон и так далее. Итоговый кадр, собранный DWM, отправляется на сканаут. Значит, помимо экспорта DMAbuf, в DXVK нужно реализовать и импорт DMAbuf (отдельные гостевые контексты соответствуют отдельным хостовым контекстам). После реализации импорта и экспорта логика swapchain на стороне хоста больше не нужна, поэтому для унификации драйвера Wine вся эта логика была перенесена в гостевой драйвер Neptune. Дополнительный плюс такого решения — более близкое соответствие архитектуре Venus, благодаря чему код virglrenderer остаётся чище.

Скриншот Windows, запущенной на QEMU на хосте Ubuntu
Композитинг Windows DWM с общими текстурами DXVK на QEMU KVM в Ubuntu

macOS

Запуск virglrenderer на macOS связан с рядом сложностей, но поскольку Venus теперь работает в macOS, большинство проблем на уровне backend уже решены. Остаётся подключить Neptune к хостовому рендереру DirectX. Есть три крупных проекта, способных решить задачу DirectX на macOS. Однако все они изначально проектировались для запуска Wine, а не для сценария с общими текстурами и общими фенсами, которые требуются Neptune и Triton.

DXVK + MoltenVK

DXVK — проект, используемый на Linux-хостах. Он транслирует D3D11 API в Vulkan API и затем использует хостовый Vulkan-драйвер для рендеринга. На Linux это работает отлично, потому что Vulkan там полноценно поддерживается на всём современном графическом оборудовании. На macOS же Vulkan обслуживается ещё одним слоем трансляции — MoltenVK, который переводит Vulkan API в Metal API. В предыдущей статье уже обсуждалась специфика запуска DXVK + MoltenVK: коротко говоря, такая связка нестабильна и требует много дополнительной работы для совместимости.

Crash Bandicoot на патченом MoltenVK + Venus + DXVK (гость)

DXMT

DXMT обходит проблему Vulkan, транслируя D3D11 напрямую в Metal (поддержка D3D12 планируется). Как и DXVK, этот проект изначально ориентирован на Wine, поэтому первым шагом стала реализация нативного варианта библиотеки. С этим форком DXMT собирается как разделяемая библиотека macOS и предоставляет дополнительные экспортируемые функции для импорта/экспорта текстур и фенсов.

Скриншот результата FireStrike с backend DXMT
FireStrike (x64) для Windows 11 ARM64 на хосте macOS с backend DXMT

Общие текстуры

Одна из главных сложностей в реализации dxmt-native — общие текстуры, пересекающие границу процессов. Такое пересечение необходимо, потому что virglrenderer запускает отдельный вспомогательный процесс для каждого контекста рендерера, поэтому примерно каждый гостевой контекст D3D соответствует отдельному процессу virgl_render_server. Такая строгая изоляция процессов позволяет рендереру аварийно завершиться без падения всей виртуальной машины. Можно было бы принудительно использовать старую модель изоляции на основе потоков и стандартные хендлы MTLTexture между границами контекстов рендерера (и в будущем это придётся сделать для порта на iOS), но мейнтейнеры upstream не хотят это поддерживать. Совместное использование ресурсов Metal между процессами — задача непростая, но есть несколько «официально поддерживаемых» способов её решить.

  1. MTLSharedTextureHandle + XPC: предпочитаемый Apple способ, но требует подключения XPC — а это отдельная головная боль. И QEMU, и virglrenderer используют файловые дескрипторы и SCM_RIGHTS для передачи хендлов между процессами, но MTLSharedTextureHandle этого не поддерживает. Для наилучшей производительности такой подход стоило бы в перспективе внедрить во всей цепочке virglrenderer, QEMU и SPICE, но на этом этапе крупных архитектурных изменений между проектами хотелось избежать.
  2. IOSurface: можно рендерить в IOSurface и делиться глобальным хендлом с любым другим процессом. Именно так реализована акселерация рендеринга в UTM, но это давно устаревшая с точки зрения Apple технология. Дополнительно приходится платить накладными расходами лишнего GPU-блиттинга в IOSurface, а значит — настраивать конвейер рендеринга в virglrenderer, что усложняет систему.
  3. CALayerHost: приватный API, используемый Chrome и другими старыми приложениями macOS, где рендеринг выполняется в отдельном процессе. По задержкам он строго хуже IOSurface (CoreAnimation находится выше по графическому стеку), а обратное преобразование (из CALayer обратно в MTLTexture) ещё сложнее. Этот приём может подойти для оффлайн-рендеринга, но не для общих текстур, которые требуют композитинга.

Ни один из этих вариантов не даёт удобного способа делиться текстурами между процессами через SCM_RIGHTS, но новую идею предложил @Drakulix (в ходе обсуждения Venus), который работал над портированием Wayland на macOS. Идея — использовать shm_open() для создания объекта общей памяти (который представляется файловым дескриптором, работающим через SCM_RIGHTS), а затем отобразить его в MTLBuffer с помощью newBufferWithBytesNoCopy:length:options:deallocator:. Так получается единая область памяти, видимая одновременно CPU и GPU, и то же самое можно повторить в другом процессе. Всё это работает на Apple Silicon благодаря UMA (Unified Memory Architecture) — единому физическому адресному пространству для CPU и GPU. Единственный минус — так можно работать только с линейными текстурами, что не самое эффективное использование памяти. Однако пока количество общих текстур невелико, проблемы это не создаёт.

Общие фенсы

Общие текстуры между разными контекстами в разных процессах — только половина задачи. Вторая половина — синхронизация. Если процесс-производитель A рисует в общую текстуру, а процесс-потребитель B компонует все общие текстуры в финальное изображение для сканаута, при попытке B начать композитинг в момент, когда A ещё рисует, возникнет разрыв кадра (tearing). Чтобы этого избежать, нужны фенсы, позволяющие процессу A блокироваться, пока B рисует, и наоборот. Ситуация усложняется тем, что нужны GPU-фенсы, поскольку GPU выполняет операции асинхронно относительно CPU. В идеале GPU-процесс B должен потреблять GPU-фенс от A без опроса со стороны CPU-процесса A. Единственный способ этого добиться — использовать MTLSharedEventHandle, который требует XPC. Однако большую часть пути можно пройти с эмулированными фенсами, опираясь на два факта.

  1. Большинство событий общих фенсов происходят на границах завершения кадра. Значит, дополнительная задержка от ожидания на стороне CPU ограничена одним фенсом на завершённый кадр.
  2. Производитель события фенса может выполняться на GPU, в то время как потребитель фенса обязан ждать на CPU. Значит, тратить циклы CPU нужно лишь на одной стороне.

Эмулированный фенс работает так: производитель вызывает ID3D11DeviceContext::ClearUnorderedAccessViewUint с адресом в буфере общей памяти, отображённом процессом-потребителем. Этот вызов API позволяет записать произвольное целое число в общую память, и оно используется для записи значения таймлайна. Запись выполняется GPU, поэтому она упорядочена относительно остальных команд рисования процесса A — значит, когда значение таймлайна записано на GPU, известно, что все команды рисования завершены. Потребитель должен опрашивать общую память (это обязательно делается на CPU, поскольку у GPU Apple нет инструкции опроса значения в памяти). Увидев обновлённое значение таймлайна, потребитель понимает, что рисование завершено и текстуру можно безопасно использовать. CPU-процесс потребителя не может поставить свои команды рисования в очередь до получения сигнала фенса, что вносит дополнительную задержку — в это время GPU потенциально простаивает в ожидании следующей отправки команд.

D3DMetal

Последняя реализация DirectX API для macOS сделана самой Apple для Game Porting Toolkit. Изначально созданный для разработчиков, тестирующих свои Windows-игры на Apple Silicon, GPT включает D3DMetal.framework — реализацию D3D11 и D3D12 на базе Metal, а также транспайлер из DXBC/DXIL (проприетарного формата шейдерного байткода Microsoft) в AIR (проприетарный формат шейдерного байткода Apple). Как и DXMT, он рассчитан на работу с Wine. Как и DXMT, он не поддерживает общие текстуры и общие фенсы, поэтому их нужно эмулировать тем же способом. Однако в отличие от DXMT, этот код не открыт, поэтому для перехвата вызовов API и изменения вывода приходится использовать свизлинг и патчинг таблиц виртуальных методов.

Именно это реализовано в d3dmetal-native — обёртке вокруг D3DMetal.framework, позволяющей использовать его вне Wine и добавляющей эти дополнительные функции. Поскольку интерфейс API проектировался совместимым с DXMT, в virglrenderer можно легко переключаться между двумя backend-ами. Результат — заметный прирост производительности по сравнению с DXMT.

Скриншот результата FireStrike с backend D3DMetal
FireStrike (x64) для Windows 11 ARM64 на хосте macOS с backend D3DMetal (Rosetta)

Стоит отметить, что поскольку D3DMetal существует только в виде x86_64-сборки (она была задумана для использования с Rosetta и Wine), весь процесс virgl_render_server приходится запускать под Rosetta. Даже с этим ограничением производительность всё равно выше, чем у DXMT на нативном ARM64.

К сожалению, условия лицензии D3DMetal прямо запрещают использование за пределами «единственной цели разработки, тестирования или оценки видеоигр для использования на устройствах Apple» и разрешают распространение «исключительно в некоммерческих целях». Это означает, что включить D3DMetal в состав готового приложения нельзя. Любопытно, что CrossOver, коммерческий дистрибутив Wine, всё же включает D3DMetal в свою сборку — по имеющимся сведениям, у них особое соглашение с Apple на этот счёт. Если у кого-то есть информация о подобной договорённости, команда UTM будет рада узнать подробности, поскольку включение D3DMetal дало бы заметный прирост производительности.

Попробовать

Всё описанное здесь распространяется с открытым исходным кодом, так что желающие могут попробовать и поделиться отзывами. Команда активно работает над тем, чтобы как можно больше изменений попало в upstream, и в скором времени UTM будет обновлён с поддержкой этих возможностей, чтобы не нужно было собирать несколько проектов вручную.

Совет: можно направить AI-ассистента на эту страницу и попросить настроить всё автоматически.

Код

Сборка (macOS)

Эти инструкции по сборке актуальны для macOS. Инструкции для Linux в основном не изменились по сравнению с предыдущей статьёй.

Всё устанавливается в один staging-префикс, и компоненты находят друг друга через каталог pkgconfig этого префикса, поэтому эти переменные нужно задать сразу и сохранить на всю сессию:

export SRC=/path/to/checkouts       # где лежат git-репозитории
export PREFIX=/path/to/prefix       # корень staging-установки: bin/ lib/ libexec/ share/
export ANGLE_INC="$SRC/WebKit/Source/ThirdParty/ANGLE/include"
export ANGLE_LIB="$PREFIX/ANGLE.xcarchive/Products/usr/local/lib"

Понадобятся Xcode (с тулчейном Metal) и командные инструменты, Meson 1.3+, Ninja, pkg-config, CMake, а также установка LLVM 15 (именно эта major-версия, с заголовками и статическими библиотеками) для DXMT.

ANGLE и libepoxy

Путь QEMU через -display cocoa,gl=es и GL-backend virglrenderer проходят через ANGLE-на-Metal, который собирается из дерева WebKit, плюс libepoxy, диспетчеризующую вызовы к нему. Это без изменений по сравнению с работой над Venus, но является обязательным условием для всего остального.

git clone --filter=tree:0 --no-checkout https://github.com/utmapp/WebKit.git "$SRC/WebKit"
git -C "$SRC/WebKit" sparse-checkout init
git -C "$SRC/WebKit" sparse-checkout set Source/ThirdParty/ANGLE Configurations Tools/ccache
git -C "$SRC/WebKit" checkout 6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7
cd "$SRC/WebKit/Source/ThirdParty/ANGLE"
xcodebuild archive \
  -archivePath "$PREFIX/ANGLE" \
  -scheme ANGLE \
  -sdk macosx \
  -arch arm64 \
  -configuration Release \
  WEBCORE_LIBRARY_DIR=/usr/local/lib \
  NORMAL_UMBRELLA_FRAMEWORKS_DIR="" \
  CODE_SIGNING_ALLOWED=NO \
  MACOSX_DEPLOYMENT_TARGET=11.0
git clone -b macos-venus https://github.com/utmapp/libepoxy.git "$SRC/libepoxy"
meson setup "$SRC/libepoxy/build" "$SRC/libepoxy" \
  "-Dc_args=-I$ANGLE_INC" \
  -Degl=yes \
  -Dx11=false \
  "--prefix=$PREFIX"
meson install -C "$SRC/libepoxy/build"

DXMT

DXMT по умолчанию собирается как cross-сборка для Wine; если не указывать cross-файл, получится нативная сборка, которая линкует все модули в единую libdxmt-native.dylib, экспортирующую точки входа D3D11/DXGI и API embedder-а (события в стиле Win32 и общие текстуры), который использует render server Neptune.

export LLVM15=/path/to/llvm@15   # корень установки arm64 LLVM 15
git clone https://github.com/utmapp/dxmt.git "$SRC/dxmt"
cd "$SRC/dxmt"
meson setup build-native \
  "-Dnative_llvm_path=$LLVM15" \
  --buildtype=release \
  "--prefix=$PREFIX"
meson install -C build-native

Для $LLVM15 подходит llvm@15 из Homebrew; сборка из исходников также описана в docs/DEVELOPMENT.md DXMT.

d3dmetal-native

D3DMetal.framework поставляется только в виде x86_64, поэтому эта библиотека — и любой процесс, загружающий её, — должны быть x86_64. Cross-файл, задающий -arch x86_64, находится в репозитории, а Rosetta 2 прозрачно запускает результат (включая набор тестов).

git clone https://github.com/utmapp/d3dmetal-native.git "$SRC/d3dmetal-native"
cd "$SRC/d3dmetal-native"
meson setup build \
  --cross-file build-macos-x86_64.txt \
  -Dtests=disabled \
  "--prefix=$PREFIX"
meson install -C build

Сам фреймворк не входит в поставку: его нужно взять из Apple Game Porting Toolkit и указать путь к нему во время выполнения через D3DMETAL_FRAMEWORK_PATH (либо задать резервный путь при компиляции через -Ddev_framework_path=...). Если macOS помещает его в карантин: xattr -dr com.apple.quarantine D3DMetal.framework.

virglrenderer

Это самая интересная часть, поскольку один процесс QEMU должен управлять нативной библиотекой arm64 и render server, который может запускаться под любой из двух архитектур. Это две конфигурации Meson для одного и того же дерева исходников:

  1. Нативный arm64 — собирает как libvirglrenderer, которую линкует QEMU, так и render server, обслуживающий Venus и backend Neptune DXMT. Только эта конфигурация устанавливается.
  2. Cross-сборка x86_64 — тот же render server для backend Neptune D3DMetal, который обязан быть x86_64, поскольку таков сам фреймворк. Он линкует virglrenderer статически и никогда не устанавливается.

Два получившихся server-бинарника затем объединяются через lipo в один universal-бинарник. Во время выполнения родительский процесс выбирает нужный «срез» воркера под каждый контекст — контексты Venus получают срез arm64, контексты Neptune получают x86_64/D3DMetal под Rosetta по умолчанию либо arm64/DXMT при NPT_BACKEND=dxmt — так что настраивать нужно только один путь render server, который «зашит» ещё на этапе сборки.

git clone -b macos-next https://github.com/utmapp/virglrenderer.git "$SRC/virglrenderer"

1. Нативный arm64 (библиотека + render server).

meson setup "$SRC/virglrenderer/build-arm64" "$SRC/virglrenderer" \
  "-Dc_args=-I$ANGLE_INC" \
  -Dvenus=true \
  -Dneptune=true \
  -Drender-server-worker=process \
  -Dcheck-gl-errors=false \
  "--pkg-config-path=$PREFIX/lib/pkgconfig" \
  "--prefix=$PREFIX"
meson install -C "$SRC/virglrenderer/build-arm64"
cp "$PREFIX/libexec/virgl_render_server" "$SRC/virglrenderer/virgl_render_server.arm64"

Нужно сохранить именно установленный server, а не тот, что находится в build-дереве: только установленная копия содержит правильный install_name, указывающий на $PREFIX/lib/libvirglrenderer.1.dylib, и именно её собирается перезаписать lipo.

2. Render server x86_64 под Rosetta. Эта конфигурация cross-компилируется с cross-файлом из репозитория d3dmetal-native. -Ddefault_library=static линкует virglrenderer в server, поэтому объединённому бинарнику не нужна x86_64-версия dylib, а -Dvtest=false отключает единственную цель, тянущую GL — libepoxy в префиксе доступна только для arm64. По той же причине конфигурация x86_64 не должна пытаться использовать EGL, поэтому для неё создаётся отдельный каталог pkg-config с отключённым EGL:

cp -R "$PREFIX/lib/pkgconfig" "$PREFIX/lib/pkgconfig-x86_64"
sed -i '' 's/epoxy_has_egl=1/epoxy_has_egl=0/' "$PREFIX/lib/pkgconfig-x86_64/epoxy.pc"
meson setup "$SRC/virglrenderer/build-x86_64" "$SRC/virglrenderer" \
  --cross-file "$SRC/d3dmetal-native/build-macos-x86_64.txt" \
  "-Dc_args=-I$ANGLE_INC" \
  -Dvenus=false \
  -Dneptune=true \
  -Dvtest=false \
  -Drender-server-worker=process \
  -Dcheck-gl-errors=false \
  -Ddefault_library=static \
  "--pkg-config-path=$PREFIX/lib/pkgconfig-x86_64" \
  "--prefix=$PREFIX"
meson compile -C "$SRC/virglrenderer/build-x86_64"

Только компиляция — установка этой конфигурации перезапишет нативную библиотеку из шага 1.

3. Объединение двух срезов.

lipo -create \
  "$SRC/virglrenderer/virgl_render_server.arm64" \
  "$SRC/virglrenderer/build-x86_64/server/virgl_render_server" \
  -output "$PREFIX/libexec/virgl_render_server"
lipo -archs "$PREFIX/libexec/virgl_render_server"   # должно вывести: x86_64 arm64

Ни один backend не линкуется напрямую: render server через dlopen подгружает libdxmt-native.dylib в срезе arm64 и libd3dmetal-native.dylib в срезе x86_64, по имени, поэтому обе библиотеки должны быть доступны через путь динамического загрузчика во время выполнения (см. раздел «Запуск»). Ни одна из них не является зависимостью сборки — отсутствие библиотеки просто отключает соответствующий backend во время выполнения, и ничего более.

QEMU

На этапе конфигурации ничего специфичного для Neptune включать не нужно — virglrenderer подхватывается через pkg-config, поэтому достаточно указать PKG_CONFIG_PATH на только что установленный префикс:

git clone -b utm-edition https://github.com/utmapp/qemu.git "$SRC/qemu"
mkdir -p "$SRC/qemu/build" && cd "$SRC/qemu/build"
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" ../configure \
  "--extra-cflags=-I$ANGLE_INC" \
  "--extra-ldflags=-L$ANGLE_LIB" \
  "--prefix=$PREFIX" \
  --target-list=aarch64-softmmu
make -j"$(getconf _NPROCESSORS_ONLN)" install

В сводке configure должно быть virglrenderer: YES и Cocoa: YES; если virglrenderer не найден, приведённая ниже строка -device virtio-gpu-gl-pci завершится ошибкой о неизвестном устройстве. make install также копирует UEFI-прошивку в $PREFIX/share/qemu; для виртуальной машины нужно сделать собственную копию хранилища переменных из pc-bios/edk2-arm-vars.fd в build-каталоге.

Сборка (Windows-драйверы)

Для сборки Windows-драйверов понадобится машина или виртуальная машина с Windows. Полная инструкция доступна здесь, а для UMD может быть удобно использовать build-mesa, упрощающий процесс сборки. Для тех, кто хочет просто протестировать драйвер, доступны готовые подписанные сборки. Обратите внимание: эти драйверы пока очень нестабильны, и устанавливать их на важные для вас виртуальные машины не стоит!

Запуск

Понадобится гостевая система Windows ARM64 (инструкции по сборке образа можно найти в других источниках, либо можно использовать UTM и скопировать образ диска). sudo требуется только для мостового сетевого режима (vmnet) — с пользовательской сетью QEMU нормально работает без повышенных прав.

export VM=/path/to/vm                    # образ диска + хранилище переменных EFI
export D3DMETAL=/path/to/D3DMetal.framework
D3DMETAL_FRAMEWORK_PATH="$D3DMETAL" \
DYLD_FALLBACK_LIBRARY_PATH="$PREFIX/lib:$ANGLE_LIB" \
ANGLE_DEFAULT_PLATFORM=metal \
VIRGL_LOG_LEVEL=debug \
"$PREFIX/bin/qemu-system-aarch64" \
  -machine virt \
  -accel hvf,ipa-granule-size=0x1000 \
  -cpu host \
  -smp cpus=4,sockets=1,cores=4,threads=1 \
  -m 4096 \
  -nodefaults \
  -vga none \
  -device virtio-ramfb-gl,hostmem=8G,blob=true,venus=true,neptune=true \
  -display cocoa,gl=es \
  -drive if=pflash,format=raw,unit=0,file.filename="$PREFIX/share/qemu/edk2-aarch64-code.fd",readonly=on \
  -drive if=pflash,unit=1,file.filename="$VM/efi_vars.fd" \
  -device nvme,drive=disk,serial=disk,bootindex=1 \
  -drive if=none,media=disk,id=disk,file.filename="$VM/windows.qcow2",discard=unmap,detect-zeroes=unmap \
  -device nec-usb-xhci,id=usb-bus \
  -device usb-tablet,bus=usb-bus.0 \
  -device usb-kbd,bus=usb-bus.0 \
  -device virtio-net-pci,netdev=net0 \
  -netdev user,id=net0,hostfwd=tcp::2222-:22

Аргументы, важные для графики:

  • -device virtio-ramfb-gl,hostmem=8G,blob=true,venus=true,neptune=trueneptune=true анонсирует capset Neptune (venus=true дополнительно анонсирует Venus для гостевого Vulkan). blob=true совместно с окном hostmemобязательное условие.
  • -accel hvf,ipa-granule-size=0x1000 — HVF отображает гостевую память с этой гранулярностью. Страницы 4 КиБ требуются для Venus и опциональны для Neptune.
  • -display cocoa,gl=es — окно Cocoa сканирует изображение через ANGLE/Metal до тех пор, пока не подключится блок сканаута Triton.
  • virtio-ramfb-gl — устройство, существующее только в форке QEMU от UTM, предоставляющее Windows POST-дисплей, который можно использовать до установки любых драйверов. При переносе на «ванильный» QEMU можно использовать ramfb, установить драйверы, а затем переключиться на virtio-gpu-gl-pci и перезагрузиться.

Переменные окружения

Переменная Эффект
DYLD_FALLBACK_LIBRARY_PATH Как QEMU находит libvirglrenderer, а render server находит libdxmt-native.dylib / libd3dmetal-native.dylib — обе загружаются через dlopen по простому имени, поэтому каталог lib из префикса должен быть в этом пути.
RENDER_SERVER_EXEC_PATH Переопределяет бинарник render server; по умолчанию используется путь libexec, зашитый на этапе сборки.
NPT_BACKEND d3dmetal (по умолчанию) или dxmt. Выбирает, какой срез universal render server будет использован для воркера Neptune: x86_64 под Rosetta для D3DMetal, нативный arm64 для DXMT.
D3DMETAL_FRAMEWORK_PATH Где libd3dmetal-native.dylib ищет D3DMetal.framework. Без этой переменной поиск идёт рядом с dylib и в соседнем каталоге Frameworks.
NPT_D3D11_LIBRARY_PATH, NPT_DXGI_LIBRARY_PATH, NPT_D3D12_LIBRARY_PATH Переопределяют backend-dylib, загружаемую для каждого интерфейса D3D. Удобно, чтобы указать на дерево сборки вместо установленной копии.
NPT_WA_FLAGS Битовая маска, принудительно задающая набор обходных решений на стороне хоста (синтез сигнатур шейдеров и подобное). NPT_WA_FLAGS=0 отключает их все, чтобы увидеть поведение «чистого» backend.
VIRGL_LOG_LEVEL / VIRGL_LOG_FILE Логирование хостового рендерера. debug выводит строки npt: из хостового модуля Neptune; они идут в stdout QEMU, если не задан файл логов.
DMN_LOG, DXMT_LOG_LEVEL Логирование для конкретных backend-ов d3dmetal-native и DXMT соответственно.
VK_DRIVER_FILES ICD MoltenVK, нужен только если требуется дополнительно работающий Venus (гостевой Vulkan) вместе с Triton.