וואלהבכירים במפלגתו של קנצלר גרמניה הביעו בו תמיכה על רקע הלחץ להתפטרESPNTransfer rumors, news: Barça, Madrid believe Haaland could leave Man City next summerThe Jerusalem PostIDF arrests Iran's Press TV journalist in West Bank, hands her to police, source confirms to 'Post'UN NewsThe world comes to New York: What’s at stake at UN General Assembly high-level weekRTP DesportoOpen de Portugal promovido à primeira divisão do golfe europeuBollywood HungamaAmit Sadh’s Pratap to release in theatres on November 20, 2026; FIRST look unveiledPunchPublic appointments should be based on competence – PRP chieftainInquirerEjercito vows to ensure funding for major railway projectsDigital SpyEmmerdale's Joe Tate to face new revenge plan after Ruby burialVilaWebEl Partit Demòcrata Europeu recorda a Llarena que ha d’aplicar la llei europea i amnistiar PuigdemontBBC NewsCarney's new love-in with EU has everything to do with TrumpGolem.deElon Musk fordert: Unternehmen sollen ihre KI-Systeme gegenseitig prüfen
The Daily Newsstand · Free, Always
Wednesday, September 16, 2026

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

Translate

В данной статье будем рассматривать технические детали графических 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 МБ — и всё это до первого кадра. В режиме навигатора эти значения могут быть ещё выше.

Отсюда две ключевые проблемы:

  1. Большое временное выделение памяти в RAM в виде всплеска.

  2. CPU Stall в RenderThread при длинных циклах компиляции буферов и текстур.

Мы задались вопросом: зачем вообще держать такую временную память, если ресурс можно создавать сразу на потоках загрузки?

Подход к решению

Нас посетила идея: а что если создавать буферы и текстуры на потоках отрисовки, сразу же освобождая временную память.

Новая схема работы: в каждом из Loading Threads создаются и компилируются VertexBuffer, IndexBuffer, Instance Buffer, Texture — там же, где они и загрузились. RenderThread получает уже готовые GPU‑ресурсы и занимается только Paint.

Total Time New = Loading Time + Compilation Time / Num_Threads + Paint Time

Тогда время компиляции будет распределено между потоками, и конечный рендер станет быстрее. Возможно, чуть возрастет нагрузка на загрузочные потоки, но общее время должно уменьшиться.

С таким решением хотелось увидеть следующие результаты:

  1. Уменьшение времени загрузки буферов и текстур: Total Time New < Total Time old.

  2. Отсутствие CPU Stall в RenderThread — теперь он будет только рисовать по большей части.

  3. Отсутствие роста всплесков временной памяти.

В решением предусмотреть освобождение памяти при движении карты для дальнейшего переиспользования.

Реализация для Metal

Для начала мы решили сделать реализацию для Metal, потому что там всё проще. У Metal есть MTLCommandQueue, которая выдаёт command buffer многопоточно. Можно взять command buffer и для текстуры выделить MTLBlitCommandEncoder, который записывает данные, далее быстро освобождаем память при завершении копирования.

Детали реализации:

  1. MTCommandQueue является Thread Safe, поэтому нужен отдельный MTLCommandBuffer для каждого потока для загрузки текстур через MTLBlitCommandEncoder.

  2. Для буферов и текстур нужен свой MTLHeap.

  3. Чтобы не использовать мьютексы, решено хранить MTCommandBuffer и списки MTLHeap для буферов и текстур в специальном RawList c синхронизацией через SpinLock. Реализиция RawList + SpinLock была написана моим коллегой Володей, за что ему большое спасибо.

  4. Сбор MTLHeap текстур и буферов в общий массив в RenderThread — для вызова MTLRenderEncoder::useHeaps(нужно для работы с ArgumentBuffers)

  5. Нужно удалять MTLHeap у которых нулевой usedSize, для переиспользования памяти.

  6. Нужно учесть техническую особенность операционной системы iOS/iPadOS - при уходе устройства в фон, например кратковременное нажатие на кнопку питания, все MTLCommandBuffer’s должны быть закомичены, на эту операцию выделяется ограниченное время. Для решения проблемы по сигналу ухода в фон, сбрасывался флаг разрешения коммита для текущих буферов, для всех закоммиченных буферов производилось ожидание в методе MTLCommandBuffer::waitUntilScheduled. При выходе из фона незакоммиченные ранее буферы отправлялись на исполнение и продолжалась работа.

Так как у нас несколько потоков загрузки и «лейблинга» (работа со спрайтами текстовыми метками), можно создать несколько специальных структур данных, предельное число которых равно количеству ядер центрального процессора. Каждая структура данных содержит свой MTLHeap и хранится в специально разработанном потокобезопасном стеке, реализованном на атомарных переменных. Сам потокобезопасный стек не занимается выделением или освобождением памяти, а вся рутина по аллокации и деаллокации хранимых структур ложится на вызывающий код, поэтому мы называем такие структуры узлами. Если при обращении к стеку извлечь очередной узел не получилось, это значит, что потоку его необходимо создать. Там же при создании узла создаётся и MTLTextureDescriptor, чтобы не аллоцировать объекты несколько раз. После этого выполняем MTLBlitCommandEncoder::copyFromBuffer, и возвращаем узел назад в потокобезопасный стек — и всё, последующий поток может извлечь уже созданный узел для того, чтобы переиспользовать его.

С буферами ситуация аналогичная, только проще — там не нужен CommandBuffer, но пока не сделаем их MTLStorageModePrivate.

Особенности Vulkan

Для Vulkan всё сложнее.

Буферы — просто. Библиотека Vulkan Memory Allocator является Thread Safe для некоторых функций. Достаточно иметь свои списки буферов, используя аналогично Metal RawList + SpinLock, аллоцировать память, создать буфер и скопировать туда данные. Нужно также удалять пустые буферы для переиспользования памяти.

Текстуры — значительно сложнее. Для копирования нужно три этапа:

По аналогии с 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.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.