А зачем?

Причина первая: уход на пенсию OpenGL для iOS/OSX. В 2018 году Apple объявила, что прекращает поддержку этого графического API, и это только вопрос времени, когда она удалит его из своих операционок и запретит выкладывать в App Store приложения, использующие GLES (OpenGL for Embedded Systems — подмножество API OpenGL для встроенных систем, например, мобильных устройств). А кому надо впопыхах интегрировать незнакомый API? Правильно, никому. Плюс, нет-нет да и случаются какие-то неприятные падения где-то под капотом OpenGL, починка которых сводится к мольбам, что очередной точечный фикс кода всё исправит.

Причина вторая: технические трудности разработки. Сейчас OpenGL просто своим существованием блочит и/или замедляет работу целому пласту iOS-разработчиков. Во-первых, счастливчики, которым довелось поиметь M1, вынуждены страдать со сборкой и Rosetta (программное решение от Apple для запуска x86_64 приложений на ARM64 процессорах, коим и является M1). Или вот ещё история: если собрать приложение в Xcode версии 13.3+ и запустить его на симуляторе iOS 15.4+, то OpenGL падает на первом кадре. Почему? Никто не знает. На issue-трекерах разных проектов много кто от этого страдает, а мы решили просто перейти на Metal.

Причина третья (продуктовая): производительность. Чисто теоретически, Metal обладает большей производительностью: меньше аллокаций, больше буферов можно переиспользовать, меньше инициализаций в render-потоке. Apple анонсировала Metal на одной из WWDC (Worldwide Developers Conference — международная конференция для разработчиков на платформах от Apple) и утверждала, что он может быть до 10 раз быстрее OpenGL. В реальности — вопрос сложный. Сильно зависит от того, где и как Metal используется, а именно, насколько вы следуете гайдлайнам. Потому что, очевидно, не всегда есть возможность сохранить удобный интерфейс для совершенно разных графических API. Производительность в рамках нашего продукта ещё обсудим.

Первый прототип

Итак, имеем следующие вводные:

  • Есть большая кодовая база, которая активно использует OpenGL, и которую, по понятным причинам, хочется как можно больше переиспользовать.

  • Несмотря на то, что другой API для рендера в момент старта перехода на Metal мы не использовали, всё равно наш код более-менее готов к этому, потому что для тестовых целей мы когда-то придумали «пустой рендерер», который высокоуровнево делает всё то же самое, но в реальности просто играется с данными, ничего не рисуя.

  • Текущая архитектура нашего графического движка OpenGL-центрична, потому что затачивались именно на него. Конечно, когда-то поддерживали DirectX, но со смертью винфона отказались от этой идеи.

  • OpenGL и Metal высокоуровнево сильно различаются, и подружить их на одной кодовой базе местами будет очень серьёзной головоломкой.

Никто в команде у нас никогда не занимался Metal всерьёз, поэтому решили начать с простого. Впервые изучая новый графический API, люди сначала учатся рисовать треугольник, так и мы хотим реализовать некий Proof-of-Concept — нарисовать карту хоть как-нибудь, чтобы иметь представление, как мы будем всё это дело интегрировать.

С какой стороны подойдём к этой задаче?

Сначала давайте посмотрим на схему работы графического конвейера (картинка отсюда, там же можно почитать подробнее):

Начнем сначала — с шейдеров. Шейдер — это небольшая программа, которая запускается на GPU. Нас интересует два вида шейдеров:

  • Вершинный. Конвертирует координаты вершин 3D-объектов в 2D-координаты экрана.

  • Фрагментный. Вычисляет финальное значение цвета пикселя.

Каждый вид объекта на карте — линия (обычная, градиентная, штриховая), здания, площадники, иконки и т.д. — рисуется с помощью какого-то своего шейдера.

У каждого API есть своя спецификация «шейдерного языка» — GLSL (OpenGL Shading Language) и MSL (Metal Shading Language) соответственно, поэтому мы не можем использовать уже существующие шейдеры в нашем прототипе. Однако переписывать или копировать шейдеры не хочется, мы ведь просто хотим нарисовать карту. Да и в долгосрочной перспективе поддерживать 2 комплекта шейдеров под каждое API будет не очень приятно. В давние времена в нашей команде уже был такой опыт, и такие комплекты постоянно расходились, потому что не всегда была возможность проверять корректность работы шейдеров DirectX под Windows: у разработчиков банально не было нужных машин.

Тут нам на помощь приходит три утилиты:

  • glslang позволяет транслировать GLSL в SPIRV (Standard Portable Intermediate Representation — промежуточный язык для вычислений и работы графики от Khronos Group, создателей OpenGL).

  • SPIRV-Cross позволяет транслировать SPIRV в MSL и генерировать рефлексию — информацию о том, как располагаются в памяти атрибуты, принимаемые на вход шейдерами.

  • xcrun позволяет скомпилировать полученный код в шейдерную библиотеку.

Небольшой скрипт на питоне, который вызывает все эти утилиты с нужными параметрами, позволяет нам получить готовую шейдерную библиотеку в compile-time. Просто прикрутим его в cmake и готово.

Но тут не всё идеально. У каждой из этих утилит имеется огромное количество рычажков, и узнать, какие нужны, а какие — нет, не так-то просто . Например, OSX, iOS на устройстве и iOS на симуляторе — каждый требует по-своему скомпилированную библиотеку. И подобных нюансов очень много. В процессе интеграции тот самый небольшой скрипт на питоне неплохо прибавил в размере за счёт большого количества ветвлений.

Теперь осталось найти гайд от рандомного индуса Apple с красивым названием вроде Metal Getting Started, как-нибудь инициализировать нужные ресурсы по методичке, что-то скопипастить, что-то прибить гвоздями, и вуаля:

А чо, надо было что-то настраивать?

Графические API предоставляют обширный инструментарий, которого мы пока никак не коснулись. Например, смешивание цветов, буфер глубины работают по конкретным правилам, которые задаёт пользователь. Как и с шейдерами, у каждого уникального типа объектов будет свой собственный набор этих правил . И каждый такой тип будет рендериться в рамках одного прохода отрисовки, который должен быть выставлен в состояние, удовлетворяющее этим правилам. 

Следующий шаг довольно очевиден: поддержка выставления этого самого состояния в Metal.

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

Давайте разберёмся, как можно подойти к этой задаче, используя наши графические API.

Все GL-ные вызовы выполняются над так называемым контекстом. Это огромный объект, который отслеживает состояние API, отправляет команды на GPU, работает с памятью и ещё куча всего. По сути является конечным автоматом, который переходит из одного состояния в другое. В Metal же весь этот контекст разделен на ряд объектов, каждый из которых делает какую-то свою одну работу:

Итак, у OpenGL есть большой монолитный контекст, и мы ограничены технологиями своего времени завязываемся на GLES 2.0, где в API ещё не появились отдельные объекты для настройки состояния. Если мы будем для каждого уникального прохода отрисовки создавать свой собственный контекст с уникальным состоянием, то получим солидный такой оверхед. Вместо этого перед каждой отправкой команд на отрисовку в GPU будем выставлять контекст в состояние, соответствующее проходу отрисовки. А ещё для каждого кадра отсортируем все команды по проходу, чтобы свести эти переключения к минимуму.

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

  1. Дескрипторы (в OpenGL они тоже есть, но там это простой GLint, который является идентификатором ресурса).

  2. Скомпилированные объекты.

Концепция очень простая. Создание дескриптора и его компиляция — трудоёмкие операции, которые должны производиться разово (по возможности) при инициализации движка. Дескриптор, как понятно из названия, — простенький объект-описание. С ним ничего нельзя сделать, кроме как поменять это самое описание, и, в конечном итоге, скомпилировать из этого описания объект. В момент компиляции полученный объект становится ресурсом GPU,  и изменить его больше нельзя, только изменять дескриптор и компилировать ресурс заново.

Таким образом, вместо одного общего контекста для каждого прохода отрисовки со своим уникальным состоянием, мы будем создавать собственный набор объектов, описывающих это самое состояние:

Теперь, когда мы с этим разобрались, остаётся несколько часов подряд погуглить, чтобы понять, как транслировать вызовы выставления опций проходов отрисовки из OpenGL в Metal, и всё, прототип готов:

Чота лагает

Картинка вроде не передаёт такой информации, но у прототипа была одна серьёзная проблема. В нем банально нельзя призумиться до уровня города. Почему? А просто не успеваем. Либо приложение падает из-за нехватки памяти или кучи аллокаций, либо подвисает на 1−2 секунды. Пока что сделаем вид, что память магически починится, и попробуем разобраться с блокировками. Для этого нужно понять, как API отличаются своим подходом к синхронизации команд.

Синхронизация вызовов в OpenGL

В общем случае утверждается, что команды отрисовки в OpenGL асинхронны. Это значит, что если пользователь вызывает какую-либо функцию glDraw*, нет гарантии, что отрисовка фактически завершится в момент выхода из функции. Вообще, вполне нормально, что она может даже не начаться. Однако API OpenGL построено на “as if” модели — все команды реализованы так, будто бы они являются синхронными. То есть реализации тратят кучу времени, отслеживая, какие вызовы что и где спродуцируют, чтобы, если пользователь сделал что-то, что требует ожидания на GPU, они могли это увидеть и подождать. Таким образом, каждая команда ведёт себя так, будто все предыдущие команды завершили свою работу. Рассмотрим на примере:


glDrawElements(...);

// ...

glReadPixels(...);

// Что-то делаем с прочитанными пикселями.
process_pixels(...);

В рамках одного контекста мы сначала рисуем что-то, а потом копируем это в память приложения, чтобы как-то обработать всё это дело на CPU. glDrawElements в общем случае не будет блокирующим вызовом (нам ведь ничто не мешает просто нарисовать объект, правильно?), однако glReadPixels заблокирует поток. OpenGL после кучи своих внутренних проверок поймёт, что рендер таргет, из которого мы хотим прочитать данные, должен измениться в ходе выполнения предыдущих команд, и нужно дождаться их завершения.

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

А чо там в Metal? 

Один из ключевых объектов API — MTLCommandQueue. В целом он обладает только одной целью в своей жизни — создавать другой не менее важный объект (или несколько, если хочется) — MTLCommandBuffer. Каждый из них, по сути, — набор инструкций, которые Metal отправит на GPU для исполнения. Однако напрямую команды в него не записываются. Вместо этого он создает ещё один объект (это последний, честно) — MTLCommandEncoder, который как раз-таки и транслирует вызовы API в инструкции GPU и записывает их в «родительский» буфер команд. После записи нужных команд энкодер завершает свою работу и высвобождается, и потом можно будет создать новый инстанс энкодера для следующего набора команд.

Кстати, энкодеров в Metal бывает 3 штуки:

  1. MTLRenderCommandEncoder занимается выставлением состояния, байндингом объектов и отрисовкой.

  2. MTLComputeCommandEncoder занимается диспатчем вычислительных шейдеров.

  3. MTLBlitCommandEncoder занимается копированием текстур и буферов.

Теперь рассмотрим, как будет выглядеть тот же самый пример в Metal:


// Базовые объекты, создаваемые единоразово при инициализации движка.
device = MTLCreateSystemDefaultDevice();
command_queue = [device newCommandQueue];
command_buffer = [command_queue commandBuffer];
pass_descriptor = [MTLRenderPassDescriptor new];

// ...

// Создаем энкодер для отрисовки.
command_encoder = [command_buffer renderCommandEncoderWithDescriptor:pass_descriptor];
[command_encoder drawPrimitives: ... ];

// Высвобождаем, потому что буфер может иметь только один активный энкодер.
[command_encoder endEncoding];

// Теперь создаем буфер для копирования.
command_encoder = [command_buffer blitCommandEncoder];
[command_encoder copyFromTexture: ... ];
[command_encoder endEncoding];

Внимательный читатель может спросить: «А куда в этом примере делась обработка пикселей?». Хороший вопрос. Штука в том, что на данный момент GPU ещё не сделал никакой работы. Для того, чтобы буфер отправил инструкции на GPU, и они начали исполняться, нужен еще один вызов API:

[command_buffer commit];

Однако если мы прямо сейчас сохраним в файл то, что прочитали, с большой вероятностью получим какую-то такую ситуацию:

Кстати, знакомьтесь, логотип команды 3D-карта ????
Кстати, знакомьтесь, логотип команды 3D-карта ????

Дело в том, что API Metal’a по своей сути абсолютно асинхронное. Синхронизация вызовов полностью отдаётся на откуп пользователю. Оно и логично — в современном мире программист при написании кода стремится к минимизации блокировок, и куда эффективней дать ему инструмент синхронизации, нежели самостоятельно реализовывать тяжелые механизмы проверок необходимости этой самой синхронизации. Отсюда и пропавший кусочек изображения — оно сохранилось в файл до того, как успело полностью скопироваться в буфер. Добавим ещё немного кода на Obj-C:

[command_buffer waitUntilCompleted];
process_pixels(...);

И тут, наконец-то, мы приходим к проблеме, с которой я начал эту главу (небось уже успели забыть, да?). В качестве вьюхи в Metal выступает объект MTKView, который содержит в себе CAMetalLayer — некую абстракцию, содержимое которой в конечном итоге отображается на экране. CAMetalLayer как раз и выдает объект, в который рендерится наше изображение, с помощью функции nextDrawable. Рассмотрим сценарий:

  • Закидываем в буфер команд отрисовку нескольких кадров, допустим, порядка 10.

  • Привязываем к каждому кадру новый drawable. Apple говорят, что так надо, почему — расскажу потом.

  • Блокируем поток с помощью waitUntilCompleted, чтобы дождаться отрисовки всех кадров.

Но есть один нюанс. CAMetalLayer обладает ограниченным пулом этих самых дроваблов. Поверим stackoverflow и будем считать, что их всего 3 но это не точно. Если в момент запроса следующего drawable в пуле нет свободных, то он заблокирует поток, пока хоть один из них освободится.

Уже начали понимать, в чём проблема? Мы дожидаемся отрисовки кучи кадров и только потом высвобождаем drawable из своих собственных объектов движка. В итоге CPU в одном потоке ждёт освобождения объекта вместо того, чтобы использовать все ресурсы на подготовку следующего кадра. По-хорошему, просить у API drawable нужно как можно позже, и тогда проблема не будет такой явной. Однако архитектура приложения нам это не совсем позволяет нормально сделать, так что будем просто бороться с блокировками. Тут все несложно:

  • не будем удерживать drawable самостоятельно, чтобы его время жизни по большей части контролировал Metal — забайндили его и тут же высвободили;

  • вместо блокирующего ожидания зададим буферу callback, который он исполнит после завершения всех инструкций:


[commandBuffer addCompletedHandler:
  ^(id<MTLCommandBuffer> command_buffer)
  {
      process_pixels(...);    
  }];

Кстати, в более свежих версиях API проблемы не существует, там можно задать таймаут, после которого Metal выйдет из функции nextDrawable и отдаст nil. Но я хотел привести наглядный пример того, что OpenGL в общем случае хоть и будет проще для написания прикладного кода, но, если пользователь будет грамотно использовать синхронизацию, приложение будет работать куда эффективнее.

Текстуры и буферы

Теперь, когда мы могли свободно передвигаться по карте, можно лить в прод нужно было выяснить, что ещё мы отломали в процессе нашего перехода. Долго ходить не пришлось, стоило призумиться до уровня города — все домики куда-то подевались:

Изображение, которое вы видите на экране, находится в какой-то области памяти GPU и называется экранный буфер (framebuffer). Он в общем случае состоит из трех элементов (или любой их комбинации):

  • Буфер цвета (color buffer). Хранит в себе цвета пикселей.

  • Буфер глубины (depth buffer). Используется для тестов глубины (что это — сейчас расскажу).

  • Буфер трафарета (stencil buffer). Используется для тестов трафарета.

При этом буфер глубины и буфер трафарета идут рука об руку и представляются одним объектом. Эти буферы задаются с помощью текстур, в которые как раз все и рендерится.

Что такое тест глубины? Если коротко, это механизм, который позволяет на этапе отрисовки определить, как пересекающиеся объекты будут расположены относительно камеры и друг друга. То есть, что будет на переднем плане, а что — на заднем.

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

После нескольких часов разглядывания текстуры буфера глубины в графическом отладчике стало понятно, что она ни разу не очищалась, поэтому-то домиков и не было — они все проигрывали в тест глубины. Значения в буфере при инициализации были слишком большими, чтобы хотя бы один фрагмент мог успешно пройти тест, поэтому они все отбрасывались.

В OpenGL буферы очищаются по требованию простой строчкой кода:


// В качестве примера здесь очищаем буфер глубины.
// Метод принимает на вход битовую маску.
// Каждый бит соответствует какому-то из буферов.
glClear(GL_DEPTH_BUFFER_BIT);

В Metal же все обстоит немножко сложнее.

У объекта MTLRenderPassAttachmentDescriptor есть настройка LoadAction, которая позволяет задать поведение буферов при старте прохода отрисовки. MTLLoadActionLoad продолжит использовать то, что уже было в текстуре до старта прохода, и будет рендерить «поверх», а MTLLoadActionClear перед рендером сначала всё очистит. При этом вы не можете очистить буфер без начала и завершения прохода отрисовки. А мы хотим, как вы помните, как можно больше переиспользовать объекты API, поэтому переключать LoadAction проходов на каждое движение камеры как-то не круто. Как быть? Создадим специальный технический очищающий проход отрисовки (вообще, два — для буфера цвета и буфера глубины/трафарета):


// Параметры для очистки буфера глубины/трафарета.
pass_descriptor.depthAttachment.loadAction = MTLLoadActionClear;
pass_descriptor.depthAttachment.storeAction = MTLStoreActionDontCare;
pass_descriptor.depthAttachment.clearDepth = 1.0f;

// Параметры для очистки буфера цвета.
pass_descriptor.colorAttachments[0].loadAction = MTLLoadActionLoad;
pass_descriptor.colorAttachments[0].storeAction = MTLStoreActionStore;
pass_descriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0);

// ...

// Теперь "отрисуем" с помощью такого прохода:
command_encoder = [command_buffer renderCommandEncoderWithDescriptor:pass_descriptor];
[command_encoder end_encoding]

// Поздравляю, нужный буфер очищен!

Теперь, когда буфер глубины действительно очищается, 3D-карта вновь начала оправдывать свое название:

Больше визуальных приколов

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

Здесь мы рисуем кадр карты, а потом отдаём пользователю текстуру, в которую сохранили этот же кадр, для превью. Давайте разбираться, что пошло не так с этой текстурой.

Чёрная полоса сверху

Это обсуждали еще в прошлой главе: мы начинаем обрабатывать пиксели до того, как текстура успевает скопироваться с GPU в наш буфер. Хотя тут тоже есть интересная ситуация. Metal позволяет создать объект-буфер MTLBuffer вокруг области памяти, которую мы выделили в плюсах. Однако, когда мы запустили приложение, собрав его в Xcode, который где-то под капотом магически включает металовские ассерты, получили кипу ошибок:

XPC API Misuse: Attempt to pass a malloc(3)ed region to xpc_shmem_create()

Дело в том, что функция API, которая копирует текстуру, требует, чтобы целевой буфер был выравнен по странице памяти. И мы выравниваем, используя posix_memalign, но Metal как-то не оценил. Пришлось заиспользовать vm_allocate.

Всё вверх ногами

Всё вызвано тем, что Metal и OpenGL используют разные системы координат (в целом про системы координат, и вообще, как 3D оказывается 2D, можно почитать тут). А именно NDC (Normalized Device Coordinates) для OpenGL стартует в нижнем левом углу и Y смотрит вверх, а для Metal — старт в верхнем левом углу, и Y смотрит вниз:

Вообще, пока вы рисуете во фреймбуфер и больше ничего не делаете, этой проблемы не возникает, под капотом все приводится в нужный вид. А вот если фреймбуфер нужно куда-то сохранить, то при использовании OpenGL необходимо перевернуть текстурку. Отключим переворачивание под Metal — и готово. Кстати, система координат вьюпорта у Metal тоже другая, так что при переходе вам может понадобиться преобразовывать MVP, про это можно почитать тут.

Что-то с цветом

API накладывает ограничения на формат пикселя в CAMetalLayer. Он обязательно должен быть в формате BGRa или какой-то его вариации вроде BGRa_s (компрессированная версия). Но отдаем мы текстуру в формате RGBa, а пиксели и не в курсе. Тут так же, как и с переворотом, под капотом всё приводится в нужный вид, проблема возникает только при отдаче куда-то наружу. У нас есть алгоритмы работы с пикселями, в которые не очень хочется вкручивать логику выбора действий на основании входного формата, поэтому просто преобразуем цвет, поменяв компоненты местами. Причём желательно с помощью GPU и специального шейдера, чтобы не насиловать CPU.

Память магически не починилась :(

Теперь, когда визуал вроде ок, остался последний шаг — оптимизировать использование API, чтобы не жрать так много оперативы. Заодно посмотрим, что там можно ускорить с точки зрения CPU. Начну с того, что Apple написали отличную короткую, но содержательную методичку на этот счет, которая называется Metal Best Practices

Кратенький TL;DR:

  • Переиспользовать всё, что можно переиспользовать

  • Создавать все тяжелое как можно раньше

  • Высвобождать всё, что можно, когда оно больше не нужно

  • Использовать минимум энкодеров

  • Равномерно распределять работу между GPU и CPU

  • Настраивать объекты, а не использовать дефолтные настройки

Теперь более предметно расскажу байки, с чем мы сталкивались, и что с этим делали.

Утечки

Каким-то чудом у них было всего два источника. Первый — CAMetalDrawable, который не высвобождался сразу после кадра, что давало где-то +4 Кб на каждые несколько кадров.

Второй — ARC (Automatic Reference Counting), который плюсовики, ни разу не нюхавшие Obj-C, вообще не выкупали. Вкратце — Obj-C использует счётчики ссылок как средство высвобождения ненужных объектов, буквально считая, сколько раз ссылаются на ресурс, и в момент, когда счетчик становится равен 0, этот ресурс становится недоступным и отправляется в список на очистку. Но в общем случае он не делает это самостоятельно, ему нужно подсказывать сообщениями retain и release. А у Clang есть механизм автоматизации этого процесса. Если компилировать плюсовые таргеты с Obj-C кодом, используя флаг -fobjc-arc, и обернуть код блоком @autoreleasepool, то компилятор в этом блоке самостоятельно расставит все retain и release, и мы избавимся от головной боли.

Работа с буферами юниформов

Юниформы — это особенные входные данные для шейдеров. Их значения будут одинаковыми для всех потоков (отсюда и название uniform — однородные) и доступными только для чтения. На каждый кадр в потоке отрисовки мы создаем буферы, хранящие в себе их значения, и отправляем их на GPU. Причём у каждого шейдера будет свой буфер, поэтому, если мы попробуем переиспользовать один и тот же объект, получим что-то вроде вот такого:

В OpenGL, если мы прибайндили какой-то ресурс к контексту, то API говорит: «Молодчинка, теперь ты можешь делать с ресурсом что хочешь, спасибо, до свидания». Это достигается разными механизмами, в том числе копированием этого самого ресурса.

Metal в этой ситуации опять перекладывает ответственность за синхронизацию на совесть разработчика. Таким образом, в отличие от OpenGL, как только мы сообщили энкодеру, что мы хотим как-либо использовать какую-то область памяти, лучше её не трогать до завершения отрисовки без веских на то оснований, иначе получится жмых. Но аллоцировать все эти буферы в потоке отрисовки не хочется, как быть?

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

Рефлексия в compile-time

О рефлексии я упоминал в главе про прототип, давайте рассмотрим здесь поподробнее. Рефлексия, если рассматривать её суть в контексте шейдеров, — это перечисление, какие атрибуты и юниформы использует шейдер, какого они размера и как расположены в памяти. Вот пример рефлексии в формате JSON, которую нам выплёвывает SPIRV-cross:


"shader_fragment_gradient_line": {
    "textures": {},
    "uniforms": {
        "shader_fragment_gradient_line_uniforms": {
            "binding": 0,
            "members": {
                "u_float_opacity": {
                    "offset": 48
                },
                "u_vec4_border2_color": {
                    "offset": 16
                },
                "u_vec4_border_color": {
                    "offset": 0
                },
                "u_vec4_pattern_color": {
                    "offset": 32
                }
            },
            "size": 64
        }
    }
}

На основании этих данных мы как раз и генерируем буфер юниформов и сообщаем GPU, где какие данные в этом буфере лежат. Однако изначально этот JSON мы не читали, вместо этого генерировали рефлексию в рантайме, используя Metal:


MTLAutoreleasedRenderPipelineReflection reflection = nil;
pipeline_state_ = [device newRenderPipelineStateWithDescriptor:pipeline_descriptor_
                                                       options:options
                                                    reflection:&reflection
                                                         error:&error];

vertex_shader->initialize_uniform_reflection_info(reflection);
fragment_shader->initialize_uniform_reflection_info(reflection);

В итоге это приводило к замедлению первого кадра, потому что инициализировать пайплайны мы можем только тогда, когда пользователь движка создаст объект рендерера и отдаст его нам. Получается, всё что нам нужно — научить движок читать JSON с рефлексией и инициализировать рефлексию шейдеров в потоке загрузки. Оставим код выше в качестве fallback’a на случай, если рефлексия куда-то потерялась. 

Переиспользование текстур

Как вы уже могли понять, GPU рендерит всё в текстуры. Причём речь не только о конечной картинке: ещё есть всякие вспомогательные штуки. Например, если мы хотим отрендерить иконки, нам не нужно каждый раз делать это заново. Мы можем собрать из всех возможных иконок так называемый атлас и, просто двигаясь по нему, вставлять кусочки в наш фрейм буфер. Все ведь играли в майнкрафт? А текстур-паки для них видели? Да-да, это всё атласы.

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

С концептами понятно, как это программировать? Как у нас тут заведено, сначала посмотрим на OpenGL. У текстур обоих видов (обычная и постоянная) будет общий интерфейс с функциями compile (отправить на GPU) и decompile (высвободить). Это необходимо, потому что OpenGL делался 25 лет назад, и в те времена было хорошим подходом самостоятельно менеджить хардварный ресурс, то есть приходится реализовывать счётчик компиляций и высвобождать ресурс, когда декомпиляции доведут его до нуля. А для постоянных текстур мы инициализируем счётчик числом на единичку больше, чтобы высвобождать его только тогда, когда из потока загрузки придет сообщение, что проект выгрузился и его иконки нам больше не нужны.

В Metal все куда проще — нам не нужны ни счётчики, ни какие-то особенные постоянные текстуры. В 21 веке у нас есть RAII, и можно не задумываться об освобождении ресурсов на таком низком уровне. Единственная вещь — для объекта текстуры завести флаг, что мы уже скомпилировали текстуру, чтобы не компилировать её заново, и просто уничтожать объект, когда он нам не нужен.

Высвобождение ресурсов

Вся философия Metal — это мильён объектов, единственная цель которых — создавать другой мильён объектов. Очевидно, что после создания конечных ресурсов GPU, вспомогательные ресурсы с большой вероятностью нам не нужны. Однако собака как раз зарыта в этом «с большой вероятностью». Например, очевидно, что после загрузки и компиляции шейдеров, шейдерная библиотека нам больше не нужна. А вот MTLRenderPassDescriptor, цель которого — создать с его помощью рендер-энкодер, понадобится нам для следующего же энкодера в рамках данного прохода отрисовки. Так что нужно смотреть, что и как конкретно используется у вас в приложении. 

Для нашего проекта я пытался сформулировать какое-то правило и пришел к простому концепту: всё, что живет в потоке загрузки, можно высвобождать, а всё, что используется в потоке отрисовки, лучше складировать. Тогда мы экономим кипу памяти и за небольшой оверхед гарантированно избегаем аллокаций в потоке отрисовки после первого кадра.

Итог

Итак, я рассказал, как на уровне концептов различаются два API, и попытался уменьшить количество болей, с которыми вы столкнетесь в процессе перехода на Metal в своих проектах. Однако, что там с нашей картой? Простите-извините, закончу клиффхенгером:

На момент написания этого итога (16.09.22) мы только закончили регрессионное тестирование и со дня на день собираемся начать раскатывать на бой релиз с включенным Metal. Как он себя чувствует на бою, что ещё мы отломали, и что там с перформансом, расскажу во второй части. Ну, или если решили вернуться в мезозой к OpenGL, тоже расскажу. До встречи через пару месяцев!

Комментарии (19)


  1. maisvendoo
    21.09.2022 10:49
    -3

    Я просто оставлю это здесь


    1. beeruser
      21.09.2022 16:01

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

      В быстро развивающейся отрасли, такой как 3D графика, API постоянно перестраиваются под текущие требования и текущее железо (и ближайшего будущего).
      OpenGL мог быть хорош в 90-х годах, но сейчас это как лоскутное одеяло, совершенно не отвечающее современным потребностям (в частности многопоточному рендеру).

      iwtp

      В Metal же все обстоит немножко сложнее.

      А мы хотим, как вы помните, как можно больше переиспользовать объекты API, поэтому переключать LoadAction проходов на каждое движение камеры как-то не круто. Как быть? Создадим специальный технический очищающий проход отрисовки (вообще, два — для буфера цвета и буфера глубины/трафарета):

      А вы не задумывались почему «сложнее»?
      В iOS (и теперь в macOS) девайсах стоит TBDR GPU, который рендерит во внутренней памяти тайлами. Соотвественно цель оптимизации это максимизировать использование тайловой памяти и минимизировать количество трансферов с основной памятью.

      Вы, пытаясь сэкономить копейку, потеряли рубль (я достаточно стар чтобы говорить поговорками). Вместо быстрой очистки перед рендером сделали лишние запись-чтение всего буфера цвета.


      1. thevlad
        21.09.2022 23:20
        +1

        Картинка как раз в тему, если бы их волновали именно технологии современного рендера то там бы был Vulkan, а OpenGL можно оставить для кучи всякого легаси. А так - очередной вендор лок, приправленные лапшей которую надо снять с ушей.


        1. beeruser
          22.09.2022 16:18
          -1

          «Лапшу с ушей», как вы выражаетесь, нужно снять любителям забивать гвозди микроскопом фанатикам технологии Х, которые хотят всем её запихнуть.
          Дело в том, что хороший API органично встроен в ОС и основан на общих механизмах. macOS весьма специфична и Vulkan там смотрится несколько чужеродным. Поэтому на Windows имеем D3D, а на macOS имеем Metal.
          Крайне смешно читать про вендорлок и другие страхи.
          Я же вижу возможность использовать железо с помощью инструмента, который предназначен именно для него.
          Не можете написать/поддерживать мультиплатформенный рендер (чем я занимаюсь уже много лет) — используйте moltenVK или к примеру bgfx.


          1. thevlad
            22.09.2022 16:25
            +2

            Отлично расскажите какие такие специфичные механизмы на уровне ядра ОС(которая к тому же unix подобная), нужны для драйвера видеокарты процентов 90 которого находится в юзер-спэйс? А то мне прямо интересно стало.


        1. Woodroof
          23.09.2022 06:32

          Но ведь Metal появился раньше, чем Vulcan.


          1. thevlad
            23.09.2022 12:13
            +1

            Это понятно, вопрос конвергенции. Мне лично не ясно, чем metal принципиально лучше vulcan. Просто apple может тащить очередной стандарт без "фатальных ошибок".


  1. Zara6502
    21.09.2022 11:26
    -4

    и запустить его на симуляторе iOS 15.4+

    резануло, всё же наверное на эмуляторе?


    1. iwtp Автор
      21.09.2022 11:34
      +3

      меня тоже подергивает от слова "симулятор", но в Xcode это называется именно так, и все окружающие меня iOS-разработчики тоже говорят так, поэтому я просто смирился :)


      1. Zara6502
        22.09.2022 06:14
        -1

        Есть такое слово - моветон, и его можно применять к статьям, где люди пишущие на массовую аудиторию не утруждают себя писать "красиво", доступно и понятно. Если какая-то узкоспециализированная система пользуется какой-то своей терминологией (кто бы сомневался что у яблока она будет иной), что отличается от общепринятых не то что терминов в индустрии, а вообще лингвистически и по смыслу, то наверное автору стоит на это указать в статье, ведь это не бложик пользователей Xcode.


        1. Viknet
          22.09.2022 12:49
          +1

          Вообще-то для разработки iOS приложений применяется именно симулятор iOS. Он не эмулирует железо iPhone, не содержит в себе всего кода операционной системы, а симулирует рантайм-окружение, напрямую пробрасывая большую часть вызовов API в macOS (благо это близкородственные системы), реализуя отсутствующие библиотеки.
          Именно на основе технологий этого симулятора работает нативный запуск iOS приложений в macOS на Apple Silicon маках.


          1. Zara6502
            23.09.2022 07:15

            Ну вот, адекватный ответ, спасибо за разъяснения.


  1. petr97
    21.09.2022 11:31
    -4

    Мне одному новый логотип команды напомнил логотип СБИС?


    1. Zara6502
      22.09.2022 06:15
      -2

      токсичность аудитории зашкаливает )


  1. apro
    22.09.2022 09:51
    +1

    Сейчас OpenGL просто своим существованием блочит и/или замедляет работу
    целому пласту iOS-разработчиков. Во-первых, счастливчики, которым
    довелось поиметь M1, вынуждены страдать со сборкой и Rosetta

    Так OpenGL есть же нативный под macOS на M1, почему они страдают с Rosetta?


    1. iwtp Автор
      22.09.2022 12:49

      GLES на симуляторах ARM64 очень много странных падений под капотом выдает, поэтому приходится на x86_64 сидеть


  1. ExLuzZziVo
    22.09.2022 10:41

    а почему не выбрали vulkan? iOS его тоже не поддерживает?


    1. iwtp Автор
      22.09.2022 12:51

      Vulkan нативно не поддерживается. Есть библиотека MoltenVK, которая по сути транслирует вызовы Vulkan в вызовы Metal. Решили, что раз переходим на новый API "с нуля", будет логичнее как раз с Metal начинать.


  1. mrwtf
    22.09.2022 10:41
    -1

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