Эволюция рендеринга в Zenith Engine: многопоточная загрузка GPU-ресурсов

В данной статье будем рассматривать технические детали графических API Metal и API Vulkan и многопоточное программирование, от читателя требуется знание перечисленных технологий.
Всем привет! Я Андрей, разработчик 3D-графики в 2ГИС.
Если вы пишете рендер на C++ и у вас есть отдельный RenderThread, то, скорее всего, вы уже сталкивались с подобным: данные грузятся в фоне, а вот создание GPU‑ресурсов всё равно происходит в потоке отрисовки через прямое обращение к графическим API Vulkan, Metal, Direct3D 12. И пока RenderThread в длинном цикле создает буферы и текстуры, он не рисует — он стоит, пока не обработает список загруженных ресурсов.
У нас это выглядело так на типичной средней сцене: до появления первого кадра на поток отрисовки приходило 532 текстуры и 2713 буферов, которые удерживали 45 МБ и 20 МБ временной памяти в RAM соответственно. Всё это нужно было доставить до GPU создать наборы буферов и текстур далее все освободить — и только потом начинать собирать батчи и рисовать.
В статье разберём, как мы вынесли создание буферов и текстур на потоки загрузки, что для этого дает Metal, почему в Vulkan всё сложнее, и какие ограничения по особенностям работы графических API пришлось учитывать.
Как всё работало
Старая схема выглядела так: в N потоках загрузки формировались данные для GPU — но только как данные в RAM. Дальше всё это уходило в RenderThread, где происходило создание GPU ресурсов через обращение к Metal/Vulkan:
VertexBuffer 1..VertexBuffer n, IndexBuffer 1..IndexBuffer n, InstanceBuffer 1..InstanceBuffer n, Texture 1..Texture_n, MutableBuffer 1..MutableBuffer n.
И только после этого — Paint.

При больших сценах и сотнях буферов и текстур итоговое время кадра становилось суммой: время загрузки + время компиляции ресурсов + время собственно рендеринга (Total Time = Loading Time + Compilation Time + Paint Time).
Также возрастала временная память в RAM - для хранения вершин, индексов и данных текстуры.
Мы собрали данные на тестовой карте. В логах наблюдались значительные колебания размеров списков ресурсов, приходящихся на загрузку и удаление. Вот примеры максимальных значений числа текстур и буферов, приходящих на компиляцию за один проход:
Textures 8 memory_size 1146880 Buffers 21 memory_size 42738
Textures 9 memory_size 1064960 Buffers 47 memory_size 279264
Textures 10 memory_size 1277952 Buffers 123 memory_size 5348
Textures 44 memory_size 3145728 Buffers 160 memory_size 4664524
Textures 68 memory_size 5013504 Buffers 139 memory_size 1334601
Полная статистика до появления первого кадра:
Total Textures 532 memory_size 45072384
Total Buffers 2713 memory_size 20396940
То есть 532 текстуры с временной памятью 45 МБ и 2713 буферов с временной памятью 20 МБ — и всё это до первого кадра. В режиме навигатора эти значения могут быть ещё выше.
Отсюда две ключевые проблемы:
Большое временное выделение памяти в RAM в виде всплеска.
CPU Stall в RenderThread при длинных циклах компиляции буферов и текстур.
Мы задались вопросом: зачем вообще держать такую временную память, если ресурс можно создавать сразу на потоках загрузки?
Подход к решению
Нас посетила идея: а что если создавать буферы и текстуры на потоках отрисовки, сразу же освобождая временную память.

Новая схема работы: в каждом из Loading Threads создаются и компилируются VertexBuffer, IndexBuffer, Instance Buffer, Texture — там же, где они и загрузились. RenderThread получает уже готовые GPU‑ресурсы и занимается только Paint.
Total Time New = Loading Time + Compilation Time / Num_Threads + Paint Time
Тогда время компиляции будет распределено между потоками, и конечный рендер станет быстрее. Возможно, чуть возрастет нагрузка на загрузочные потоки, но общее время должно уменьшиться.
С таким решением хотелось увидеть следующие результаты:
Уменьшение времени загрузки буферов и текстур: Total Time New < Total Time old.
Отсутствие CPU Stall в RenderThread — теперь он будет только рисовать по большей части.
Отсутствие роста всплесков временной памяти.
В решением предусмотреть освобождение памяти при движении карты для дальнейшего переиспользования.
Реализация для Metal
Для начала мы решили сделать реализацию для Metal, потому что там всё проще. У Metal есть MTLCommandQueue, которая выдаёт command buffer многопоточно. Можно взять command buffer и для текстуры выделить MTLBlitCommandEncoder, который записывает данные, далее быстро освобождаем память при завершении копирования.
Детали реализации:
MTCommandQueue является Thread Safe, поэтому нужен отдельный MTLCommandBuffer для каждого потока для загрузки текстур через MTLBlitCommandEncoder.
Для буферов и текстур нужен свой MTLHeap.
Чтобы не использовать мьютексы, решено хранить MTCommandBuffer и списки MTLHeap для буферов и текстур в специальном RawList c синхронизацией через SpinLock. Реализиция RawList + SpinLock была написана моим коллегой Володей, за что ему большое спасибо.
Сбор MTLHeap текстур и буферов в общий массив в RenderThread — для вызова MTLRenderEncoder::useHeaps(нужно для работы с ArgumentBuffers)
Нужно удалять MTLHeap у которых нулевой usedSize, для переиспользования памяти.
Нужно учесть техническую особенность операционной системы iOS/iPadOS - при уходе устройства в фон, например кратковременное нажатие на кнопку питания, все MTLCommandBuffer’s должны быть закомичены, на эту операцию выделяется ограниченное время. Для решения проблемы по сигналу ухода в фон, сбрасывался флаг разрешения коммита для текущих буферов, для всех закоммиченных буферов производилось ожидание в методе MTLCommandBuffer::waitUntilScheduled. При выходе из фона незакоммиченные ранее буферы отправлялись на исполнение и продолжалась работа.
Так как у нас несколько потоков загрузки и «лейблинга» (работа со спрайтами текстовыми метками), можно создать несколько специальных структур данных, предельное число которых равно количеству ядер центрального процессора. Каждая структура данных содержит свой MTLHeap и хранится в специально разработанном потокобезопасном стеке, реализованном на атомарных переменных. Сам потокобезопасный стек не занимается выделением или освобождением памяти, а вся рутина по аллокации и деаллокации хранимых структур ложится на вызывающий код, поэтому мы называем такие структуры узлами. Если при обращении к стеку извлечь очередной узел не получилось, это значит, что потоку его необходимо создать. Там же при создании узла создаётся и MTLTextureDescriptor, чтобы не аллоцировать объекты несколько раз. После этого выполняем MTLBlitCommandEncoder::copyFromBuffer, и возвращаем узел назад в потокобезопасный стек — и всё, последующий поток может извлечь уже созданный узел для того, чтобы переиспользовать его.
С буферами ситуация аналогичная, только проще — там не нужен CommandBuffer, но пока не сделаем их MTLStorageModePrivate.
Особенности Vulkan
Для Vulkan всё сложнее.
Буферы — просто. Библиотека Vulkan Memory Allocator является Thread Safe для некоторых функций. Достаточно иметь свои списки буферов, используя аналогично Metal RawList + SpinLock, аллоцировать память, создать буфер и скопировать туда данные. Нужно также удалять пустые буферы для переиспользования памяти.
Текстуры — значительно сложнее. Для копирования нужно три этапа:
Барьер на перевод в режим записи данных (VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL).
Копирование данных vkCmdCopyBufferToImage — умеет очередь с VK_QUEUE_TRANSFER_BIT.
Барьер на перевод из режима записи данных в режим чтения в шейдерах (VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) — умеет очередь с VK_QUEUE_GRAPHICS_BIT.
По аналогии с Metal нужен VkCommandBuffer + VkCommandPool для каждого потока. Но эти операции можно сделать в потоке загрузки только частично/опционально: не на всех устройствах есть несколько VkQueue с поддержкой VK_QUEUE_GRAPHICS_BIT.
Если отдельной графической очереди нет, возникает неэффективная схема: пришла текстура → нужно попросить поток рендера перевести layout → затем копировать → затем снова перевести layout, чтобы читать в шейдере. Мы будем ждать и передавать данные между потоками, а смысл задачи — грузить сразу на потоках загрузки.
Альтернатива — использовать SecondaryCommandBuffer для перевода режима использования текстур, исполняя записанные команды через vkCmdExecuteCommands в RenderThread. Но такое решение достаточно усложнит VulkanRender, и мы снова упираемся в RenderThread. Более того, не все устройства имеют отдельную очередь с VK_QUEUE_TRANSFER_BIT — в таком случае исполнение vkCmdExecuteCommands не имеет смысла и не даст экономии выделения временной памяти, добавит дополнительную синхронизация между RendeThread и потоками загрузки.
Сравнение архитектур Metal и Vulkan
После того как обе реализации были продуманы, стало хорошо видно, насколько по-разному Metal и Vulkan относятся к идее многопоточной загрузки ресурсов. И разница эта начинается на самом базовом уровне — на уровне очередей.
В Metal MTCommandQueue является Thread Safe: она может раздавать MTLCommandBuffer в разных потоках и исполнять их. Это изначально заложено в дизайне Metal API, и именно поэтому вся задача решилась относительно просто — достаточно было завести отдельный command buffer на поток и не забыть про финализацию.
В Vulkan ситуация обратная: VkQueue не ThreadSafe, и для разных потоков нужны отдельные очереди — отдельные VK_QUEUE_GRAPHICS_BIT и/или VK_QUEUE_TRANSFER_BIT. Если таких очередей на устройстве нет, приходится опционально использовать Secondary Command Buffer и передавать его на vkCmdExecuteCommands в RenderThread — то есть возвращаться ровно к той зависимости, от которой мы и хотели уйти.
Отсюда и главное расхождение — в загрузке текстур. В Metal это простейшая загрузка за один этап с немедленным освобождением временной памяти, и никакого ограничения на число MTLCommandBuffer нет. В Vulkan загрузка текстур сложнее именно из-за разной реализации очередей: без отдельных очередей, с использованием Secondary Command Buffer, память всё равно будет удержана для RenderThread — то есть экономии во временной памяти не будет вовсе. При этом число доступных очередей фиксировано и ограничено реализацией, так что универсального решения тут не построить.
А вот с буферами всё сходится: и в Vulkan, и в Metal мы получаем ровно то, что задумывали — загрузку буферов на потоке загрузки с освобождением временной памяти. Именно поэтому буферная часть и стала похожей в обоих API.
Единственное, что остается специфичным для Metal, — финализация. Перед отрисовкой нужно получать список MTLHeap в RenderThread, собирая все созданные в LabelingThreads и LoadingThreads, и вызывать MTLRenderCommandEncoder::useHeaps. В Vulkan такого шага нет — ресурсы привязаны к дескрипторам, и дополнительной сборки не требуется.
Выводы и дальнейшие планы
Несмотря на сложности, часть загрузки мы смогли ускорить. В Metal реализована загрузка буферов текстур. В Vulkan реализовано только создание буферов, копирование данных в текстуру происходит в RenderThread.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.