- PVSM.RU - https://www.pvsm.ru -

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS

— I’m too young to die.

SceneKit — высокоуровневый фреймворк трехмерной графики в iOS, который помогает создавать анимированные сцены и эффекты. Он включает в себя физический движок, генератор частиц и набор простых действий для 3D-объектов, которые позволяют описать сцену в терминах контента — геометрии, материалов, освещения, камер — и анимировать её через описание изменений для этих объектов.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 1

Сегодня мы внимательным, немного суровым взглядом посмотрим на SceneKit, но, для начала обратимся к основам и посмотрим, что представляет из себя 3D-сцена и что нужно сделать, чтобы её создать.

Простейшая сцена из трёх узлов с геометрией в них.
Простейшая сцена из трёх узлов с геометрией в них

Для начала нужно создать основную структуру сцены, которая состоит из нод [1] или узлов сцены. Каждая нода может содержать в себе как геометрию, так и другие ноды. Геометрия может быть как простой, вроде шара, куба, или пирамиды, так и более сложной, созданной во внешних редакторах.

Накладываем материалы
Накладываем материалы

Затем для этой геометрии необходимо задать материалы [2], которые будут определять базовое представление объектов. Каждый материал сам задаёт свою модель освещения и в зависимости от неё использует различный набор свойств [3]. Каждое такое свойство обычно представляет из себя цвет или текстуру, но помимо этих частоиспользуемых вариантов есть ещё возможность использовать CALayer [4], AVPlayer [5], и SKScene [6].

Добавляем источники освещения
Добавляем источники освещения

После этого необходимо добавить источники освещения [7], которые определяют то, насколько хорошо видны объекты в той или иной части сцены. Они, по аналогии с геометрией, должны лежать внутри какой-нибудь ноды. SceneKit поддерживает много разных типов освещения [8], а также несколько видов теней [9].

Эффект бокэ «из коробки»
Эффект бокэ «из коробки»

Затем нужно создать камеру [10] (и положить её в отдельную ноду) и задать для неё основные параметры. Их довольно много, но с помощью них можно создавать крутые эффекты. Из коробки поддерживается эффект боке (или размытия), HDR с адаптацией, свечение, SSAO и модификации оттенка/насыщенности.

Простые анимации в SceneKit’е
Простые анимации в SceneKit’е

И наконец, SceneKit включает в себя простой набор действий для 3D-объектов, которые позволяют задать изменения сцены во времени. Также SceneKit поддерживает действия, описанные на языке JavaScript [11], но это тема для отдельной статьи.

Взаимодействие генератора частиц с физическим движком могут приводить к торнадо!
Взаимодействие генератора частиц с физическим движком может приводить к торнадо!

Помимо графики, главными фишками SceneKit являются генератор частиц и продвинутый физический движок, который позволяет задавать реальные физические свойства как обычным объектам, так и частицам из генератора.

Про все эти фишки написано большое количество подробных туториалов. Но в процессе разработки мы эти возможности практически не использовали…

Hey, not too rough

Однажды я написал модель освещения для 3D-игр лучше реального солнечного света, дающую приемлемую FPS на Nvidia 8800, но я решил не выпускать движок в свет, так как Бог мне симпатичен и я не хочу показывать его некомпетентность в этом вопросе.
— Джон Кармак

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

Есть несколько способов, и все они имеют свои плюсы и минусы:

  1. SCNScene(named:) — получает сцену из бандла,

  2. SCNScene(url:options:) — загружает сцену по URL,

  3. SCNScene(mdlAsset:) — конвертирует сцену из разных форматов,

  4. SCNReferenceNode(url:) — лениво загружает сцену.

Получаем сцену из бандла

Можно воспользоваться стандартным методом [12]: положить нашу модель в формате dae или scn в бандл scnassets и загрузить её оттуда по аналогии с UIImage(named:).

Но что если вы хотите сами контролировать обновление моделей, не выпуская апдейт в App Store каждый раз, когда вам нужно поменять пару текстур? Или представим, что вам нужно поддержать созданные пользователями карты и модели. Или — что вы просто не хотите увеличивать размер приложения, так как 3D-графика в нём не является основной функциональностью.

Загружаем сцену по URL

Можно использовать конструктор сцены из URL [13] scn-файла. Этот способ поддерживает загрузку не только из файловой системы, но и из сети, но в последнем случае можно забыть о сжатии. Плюс вам потребуется заранее сконвертировать модель в формат scn. Можно, конечно, использовать и dae, но с ним приходит набор ограничений. Например — отсутствие physically based-рендеринга.

Главный плюс этого способа — он позволяет гибко настраивать параметры импорта [14]. Можно, например, модифицировать жизненный цикл анимаций и заставить их бесконечно повторяться. Можно явно указать источник для загрузки внешних ресурсов вроде текстур, можно конвертировать ориентацию и масштаб сцены, создавать отсутствующие нормали для геометрии, смержить всю геометрию сцены в одну большую ноду или отбросить все не соответствующие стандарту формата элементы сцены.

Конвертируем сцену из разных форматов

Третий вариант — использовать конструктор с MDLAsset [15]. То есть сначала мы создаём MDLAsset [16], доступный во фреймворке ModelIO и затем передаём его в конструктор для сцены.

Этот вариант хорош тем, что позволяет загружать много различных форматов. Официально MDLAsset умеет загружать форматы obj, ply, stl и usd, но прогнав список всех возможных форматов, хоть как-то связанных с компьютерной графикой, я нашёл еще четыре: abc, bsp, vox и md3, но они могут поддерживаться не полностью или не во всех системах, и для них нужно проверять корректность импорта.

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

Эти способы имеют один общий подводный камень: они возвращают SCNScene, а не SCNNode. Единственный способ добавить контент в уже существующую сцену — скопировать все дочерние ноды и — этот шаг можно легко пропустить — анимации из корневой ноды (они, например, могут появиться там при работе с dae). К тому же нужно учитывать, что в сцене может быть только одна текстура окружения (если вы не используете кастомные шейдеры для отражений).

Лениво загружаем сцену

Четвертый вариант — использовать SCNReferenceNode [17]. Он возвращает не сцену, а ноду, которая может сама лениво (или по запросу) загружать в себя всю иерархию сцены. Таким образом, этот способ аналогичен первому, но он скрывает внутри себя все проблемы с копированием.

У него есть одно но: глобальные параметры сцены теряются.

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

В итоге мы остановились на первом варианте, так как нам было удобнее всего работать в формате scn, а дизайнерам — конвертировать в него из формата dae. Кроме того, нам понадобился файн-тюнинг анимаций при загрузке.

Вовсе не преждевременные оптимизации

Повозившись с этим процессом достаточно долго, я могу дать вам несколько советов.

Самый главный совет — конвертируйте файлы в scn заранее. Тогда вы сможете, открыв файл во встроенном в Xcode редакторе сцен, увидеть, как именно будет выглядеть ваш объект в SceneKit.

К тому же на самом деле scn-файл — всего лишь бинарное представление сцены, так что загрузка из него займёт меньше всего времени. Для того же dae нужно сначала распарсить xml, потом сконвертировать все меши, анимации и материалы. Тем более, что конвертация анимаций и материалов — потенциальный источник проблем. Вспоминаем отсутствие поддержки PBR в dae: получается, если вы хотите его использовать, вам придётся после конвертации сменить тип всех материалов и вручную проставить соответствующие текстуры.

При этой операции можно получить очень полезный сайд-эффект: значительное сжатие текстур. Достаточно открыть их в «Просмотре» и экспортировать, сменив формат на heic. В среднем эта простая операция сэкономила по 5 мегабайт на модель.

Также, если вы скачиваете сцену из интернета, могу посоветовать загружать её в архиве, распаковывать её и передавать URL распакованного scn-файла. Это сэкономит вам и пользователю лишние мегабайты — что, в свою очередь, ускорит загрузку, а также позволит уменьшить количество точек отказа. Согласитесь: делать отдельный запрос на каждый внешний ресурс, да ещё и на мобильном интернете — не самый лучший способ повысить надёжность.

Hurt me plenty

Когда я еду на машине, я часто слышу, как потрескивает жёсткий диск Вселенной, подгружая следующую улицу.
— Джон Кармак

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

Констрейнты в SceneKit считаются сразу после физики. И перед рендерингом кадра
Констрейнты в SceneKit считаются сразу после физики. И перед рендерингом кадра

Констрейнты, скажете вы? Какие констрейнты? Мало кто знает, а тем более и рассказывает об этом, но в SceneKit есть свой набор констрейнтов. И хотя они не такие гибкие, как констрейнты в UIkit, с помощью них всё равно можно сделать много интересного.

SCNReplicatorConstraint
SCNReplicatorConstraint

Начнём с простого констрейнта — SCNReplicatorConstraint [18]. Всё, что он делает, это дублирует позицию, поворот и размер другого объекта c дополнительными оффсетами. Как и у всех остальных констрейнтов, у него можно менять силу и выставлять флаг инкрементальности. Лучше всего оба параметра можно показать на этом констрейнте.

Уменьшили силу в 10 раз
Уменьшили силу в 10 раз

Сила [19] влияет на то, с каким коэффициентом трансформация применяется к объекту. И раз положение объекта-цели меняется каждый кадр — шадоу-объект приближается к нему на одну десятую от разницы расстояний. Из-за этого появляется эффект запаздывания.

Убрали инкрементальность и уменьшили силу в 10 раз
Убрали инкрементальность и уменьшили силу в 10 раз

Инкрементальность [20], в свою очередь, влияет на то, отменяется ли констрейнт после рендеринга. Предположим, мы его выключили. Тогда видим, что на каждом кадре констрейнт применяется перед рендерингом, а после рендеринга отменяется, и так повторяется каждый кадр. В результате, комбинируя эти два параметра, можно получить довольно интересный эффект стрелки часов.

Плоскость всегда стоит лицом к камере
Плоскость всегда стоит лицом к камере

Перейдём к более интересному констрейнту: так называемому биллборду.

Допустим, необходимо, чтобы некоторый объект всегда находился к нам «лицом». Для этого нужно всего лишь использовать SCNBillboardConstraint [21], указать, вокруг каких осей объект может поворачиваться. Дальше, перед просчётом каждого кадра (после шага с физикой) позиции и ориентации всех объектов будут обновляться, чтобы удовлетворить всем констрейнтам.

Тут можно упомянуть Look At Constraint [22]: он аналогичен биллборду, только объект можно поставить лицом к любому другому объекту сцены вместо текущей камеры.

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

Держит дистанцию между объектами
Держит дистанцию между объектами

SCNDistanceConstraint [23] позволяет задать минимальное и/или максимальное расстояние до позиции другого объекта. И да, с его помощью можно сделать змейку. :) Этот констрейнт тоже можно использовать для привязки камеры к персонажу, хотя положение камеры обычно задаётся более сложно, и описать его одними констрейнтами — непростая задача. Такого же эффекта можно достичь за счёт добавления пружины в физическом движке, но эту пружину можно дополнить констрейнтом на случай, если нужно избежать проблем с излишним растягиванием или сжатием пружины.

Многие видели в каком-нибудь Hitman, Fallout или Skyrim: ты тащишь за собой тело, оно задевает препятствие — и начинает вести себя так, как будто в него вселился демон. Этот констрейнт помог бы избежать подобных багов.

SCNSliderConstraint
SCNSliderConstraint

SCNSliderConstraint [24] позволяет задать минимальное расстояние между заданным объектом и физическими телами с подходящей маской коллизий. Довольно забавный констрейнт, но опять же, его стараются симулировать с помощью физического взаимодействия. Основная идея — задать радиус мертвой зоны с физическими телами для объекта, который не имеет физического тела.

Инверсная кинематика в работе
Инверсная кинематика в работе

SCNIKConstraint [25] — самый интересный, но и самый сложный констрейнт, который использует так называемую инверсную кинематику. Используя цепочку родительских узлов, инверсная кинематика итеративно пытается приблизить к необходимой точке положение ноды, к которому вы применяете этот констрейнт. По сути, он позволяет не думать, в каком положении должны находиться плечо и предплечье, а просто задать положение кисти руки и возможные углы поворота связующих узлов. Остальное посчитается за вас. Основной недостаток этого констрейнта — он позволяет задать лишь положение кисти руки, но не её ориентацию, да и ограничения на углы можно делать глобальным, без разбивки по осям.

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

Вот плоскость есть, а вот её нет
Вот плоскость есть, а вот её нет

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

Но тени — не единственный слабо изученный эффект в SceneKit. Давайте теперь разберемся с зеркалами.

Зеркало из SCNFloor — что может быть проще
Зеркало из SCNFloor — что может быть проще

Все, кто игрался со SceneKit, наверняка знают о scnfloor, который добавляет зеркальные отражения для пола. Но почему-то очень немногие используют его для честных зеркальных отражений, ведь можно над геометрией пола положить свою модельку, немного наклонить и превратить его… в обычное зеркало.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 18

Потёки на стекле и кривое зеркало
Потёки на стекле и кривое зеркало

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

Ultra-Violence

Однажды я целовался с девушкой с открытыми глазами. Ближней плоскостью отсечения девушке рассекло лицо. С тех пор я целуюсь только с закрытыми глазами.
— Джон Кармак

Тени, зеркала — интересные эффекты. Но есть один эффект, который при умелом использовании может оказаться ещё более интересным, — видеотекстуры.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 20

Обычное видео и видео с картой высот
Обычное видео и видео с картой высот

Они могут вам понадобиться, просто чтобы показать видео внутри игры. Но гораздо интереснее, что с помощью видеотекстур вы можете модифицировать геометрию. Для этого вам нужно положить видеотекстуру с картой высот в свойство displacement [26] вашего материала и использовать материал на плоскости с достаточно большим количеством сегментов [27]. Осталось понять, как же её туда положить.

Я упоминал в описании процесса создания сцены, что в качестве свойства материала вы можете использовать SKScene [28], а это — SpriteKit'овая сцена. SpriteKit — он как SceneKit, но для 2D-графики. В нём есть поддержка отображения видео при помощи SKVideoNode [29]. Вам только нужно положить SKVideoNode в SKScene, а SKScene в SCNMaterialProperty, и всё будет готово.

Но экспортировав полученную 3D-сцену и открыв её где-нибудь еще, мы увидим чёрный квадрат. Покопавшись в scn-файле, я нашёл причину. Оказывается, при сохранении видеонода не сохраняет URL видео. Казалось бы, берёшь и правишь. Но не всё так просто: scn-файл представляет из себя так называемый binary plist, в котором лежит результат работы NSKeyedArchiver. И материал, который является SpriteKit’овой сценой, представляет из себя такой же binary plist, который, получается, уже лежит внутри другого binary plist! Хорошо, что уровней вложенности всего два.

Ну а теперь мы перейдём даже не к эффекту, а к инструменту, который позволит вам создать какие угодно эффекты. Это модификаторы шейдеров.

Перед тем, как что-то модифицировать, надо понять, что именно мы модифицируем. Шейдер по определению — это программа для GPU, которая прогоняется для каждой вершины и для каждого пикселя. Таким образом, шейдер — программа, которая определяет, как выглядит объект на экране.

Ну а шейдер-модификаторы позволяют менять результаты работы стандартных шейдеров на GLSL или Metal Shading Language. Они, к тому же, доступны в визуальном редакторе, что позволяет видеть изменения в модификаторе в реальном времени.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 22

Мех и Parallax Mapping
Мех и Parallax Mapping

С помощью шейдер-модификаторов можно создавать сложнейшие визуальные эффекты. Вот, например, парочка самых известных эффектов: Мех и Parallax Mapping [30].

#pragma arguments
texture2d bg;
texture2d height;
float depth;
float layers;

#pragma transparent

#pragma body
constexpr sampler sm = sampler(filter::linear, s_address::repeat, t_address::repeat);
float3 bitangent = cross(_surface.tangent, _surface.normal);
float2 direction = float2(-dot(_surface.view.rgb, _surface.tangent), dot(_surface.view.rgb, _surface.bitangent));
_output.color.rgba = float4(0);

for(int i = 0; i < int(floor(layers)); i++) {
    float coeff = float(i) / floor(layers);
    float2 defaultCoords = _surface.diffuseTexcoord + direction * (1 - coeff) * depth;
    float2 adjustment = float2(scn_frame.sinTime + defaultCoords.x, scn_frame.cosTime) * depth * coeff * 0.1;
    float2 coords = defaultCoords + adjustment;
    _output.color.rgb += bg.sample(sm, coords).rgb * coeff * (height.sample(sm, coords).r + 0.1) * (1.0 - coeff);
    _output.color.a += (height.sample(sm, coords).r + 0.1) * (1.0 - coeff);
}

return _output;

Ray Casting с каустиками в реальном времени.
Ray Casting с каустиками в реальном времени

Что ещё интереснее, никто не мешает полностью выкинуть результаты их работы и написать свой рендерер. Например, можно попробовать реализовать Ray Casting в шейдерах. И всё это работает достаточно быстро, чтобы обеспечить 30 FPS даже на таких сложных вычислениях. Но это тема для отдельного доклада. Приходите на Mobius [31]!

Nightmare!

Я не люблю моргать, т. к. закрытые веки резко нагружают GPU для BDPT из-за недостатка освещения.
— Джон Кармак

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

Давайте сначала обратимся к самому простому решению: ReplayKit [32]. Выясним, почему оно не подходит. Вообще говоря, это решение позволяет в несколько строк кода создать запись экрана и сохранить её через системное превью. Но. У него есть большой минус — оно записывает всё, весь UI, в том числе и все кнопки на экране. Это было первое наше решение, но по очевидным причинам его в продакшен пускать было нельзя: видеозаписью должны были делиться пользователи, и делиться не из системного превью.

Мы оказались в ситуации, когда решение нужно было написать с нуля. Совсем с нуля. Итак, давайте посмотрим, каким образом в iOS можно создать своё видео и записать туда свои кадры. Всё довольно просто:

Процесс записи
Процесс записи

Нужно создать сущность, которая будет записывать файлы, — AVAssetWriter [33], добавить в неё видеопоток — AVAssetWriterInput [34], и создать для этого потока адаптер, который будет конвертировать наш пиксельбуфер в необходимый потоку формат — AVAssetWriterPixelBufferAdaptor [35].

На всякий случай напоминаю, что пиксельбуфер [36] — сущность, которая представляет собой кусок памяти, где каким-то образом записаны данные для пикселей. По сути это низкоуровневое представление картинки.

Но как получить этот пиксельбуфер? Решение простое. У SCNView есть замечательная функция .snapshot() [37], которая возвращает UIImage. Нам всего лишь нужно из этого UIImage создать пиксельбуфер.

var unsafePixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(NULL, self.pixelBufferPool, &unsafePixelBuffer)

guard let pixelBuffer = maybePixelBuffer else { return }

    CVPixelBufferLockBaseAddress(pixelBuffer, 0)
    let data = CVPixelBufferGetBaseAddress(pixelBuffer)

    let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
    let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
    let rowBytes = NSUInteger(CVPixelBufferGetBytesPerRow(pixelBuffer))

    let context = CGContext(
        data: data, 
        width: image.width, 
        height: image.height, 
        bitsPerComponent: 8, 
        bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), 
        space: rgbColorSpace, 
        bitmapInfo: bitmapInfo.rawValue
    )
    context?.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))

    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0)

    self.appendPixelBuffer(pixelBuffer, withPresentationTime: presentationTime)

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

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 26

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

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 27

А вот и нет. Такое решение даже на мощных телефонах вызывает жуткие лаги и просадки FPS. Давайте займёмся оптимизацией.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 28

Допустим, нам не нужно 60 FPS. Мы даже будем довольны 25-ю. Но как проще всего добиться такого результата? Конечно, нужно просто вынести всё это на фоновый поток. Тем более, что по утверждениям разработчиков эта функция потокобезопасна.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 29

Хм, лагать стало меньше, но видео перестало записываться…

Всё просто. Как говорится, если у тебя есть проблема, и ты её будешь решать при помощи нескольких потоков — у тебя станет 2 проблемы.

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

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 30

Давайте тогда не записывать новый буфер до тех пор, пока предыдущая запись не закончится.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 31

Хм, стало значительно лучше. Но всё равно, почему лаги появились изначально?

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 32

Оказывается, функция .snapshot() [37], с помощью которой мы получаем изображение с экрана, на каждый вызов создаёт новый рендерер, рисует кадр с нуля и возвращает его, а не то изображение, которое присутствует на экране. Это приводит к забавным эффектам. Например, физическая симуляция происходит вдвое быстрее.

Но подождите — зачем мы каждый раз пытаемся отрендерить новый кадр? Наверняка где-то можно найти тот буфер, который выводится на экран. И действительно, доступ к такому буферу есть, но он весьма нетривиален. Нам нужно из Metal получить CAMetalDrawable [38].

К сожалению, напрямую из SCNView добраться до Metal не так просто по довольно понятной причине — в SceneKit тип API можно выбрать самому, но если заглянуть под капот и посмотреть на layer [39], можно увидеть, что в качестве него выступает, в случае с Metal, CAMetalLayer [40].

Но и тут нас ждёт неудача: в CAMetalLayer единственный способ взаимодействовать с представлением — функция nextDrawable, которая возвращает не занятый CAMetalDrawable. Подразумевается, что вы запишете в него данные и вызовете у него функцию present, которая и отобразит его на экране.

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

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

И если мы в наследнике начнём в ответ на каждый вызов nextDrawable() сохранять его, получим почти то, что нам нужно. Проблема в том, что сохраненный CAMetalDrawable — это тот, в котором прямо сейчас рисуется изображение.

Прыжок к реальному решению очень прост — мы сохраняем как текущий Drawable, так и предыдущий.

И вот оно, готово — прямой доступ к памяти через CAMetalDrawable.

var unsafePixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(NULL, self.pixelBufferPool, &unsafePixelBuffer)

guard let pixelBuffer = maybePixelBuffer else { return }

CVPixelBufferLockBaseAddress(pixelBuffer, 0)
let data = CVPixelBufferGetBaseAddress(pixelBuffer)

let width: NSUInteger = lastDrawable.texture.width
let height: NSUInteger = lastDrawable.texture.height
let rowBytes: NSUInteger = NSUInteger(CVPixelBufferGetBytesPerRow(pixelBuffer)

lastDrawable.texture.getBytes(
data, 
bytesPerRow: rowBytes, 
fromRegion: MTLRegionMake2D(0, 0, width, height), 
mipmapLevel: 0
)

CVPixelBufferUnlockBaseAddress(pixelBuffer, 0)

self.appendPixelBuffer(pixelBuffer, withPresentationTime: presentationTime)

Итак, теперь мы не создаём контекст и рисуем UIImage в нём, а копируем один кусок памяти в другой. Возникает вопрос: а как же формат пикселей?..

Он не совпадает с deviceColorSpace [41]… И не совпадает с частоиспользуемыми [42] цветовыми пространствами…

Это ровно тот пункт, где сломался [43] автор одного из публичных подов, который выполняет ту же задачу. Все остальные даже до сюда не добрались.

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 33

Что же, все эти трюки — ради жутковатого фильтра?

Ну уж нет! В статье про ARKit можно найти упоминание того, что изображение с камеры использует не стандартное цветовое пространство, а расширенное. И даже представлена матрица трансформации цветового пространства. Но зачем заниматься трансформацией, если можно попробовать записать прямо в этом формате? Осталось узнать, какой это формат из 60 доступных…

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 34

И тут я занялся перебором. Записывал по три видео в разные потоки с разными форматами, сменяя их при каждой записи.

В результате примерно на сороковом формате мы получаем его название. Оказывается, это не кто иной, как kCVPixelFormatType_30RGBLEPackedWideGamut [44]. Как же я не догадался?

Doom of SceneKit. Опыт работы Яндекса с 3D-графикой в iOS - 35

Но моя радость продолжалась до первого тестера. У меня не было слов. Как? Я же только что потратил кучу времени на поиск правильного формата. Хорошо, что проблема локализовалась быстро — баг воспроизводился стабильно и только на 6s и 6s Plus. Почти сразу после этого я вспомнил, что дисплеи с поддержкой wide-gamut начали ставить только в седьмых айфонах.

Поменяв wide-gamut на старый-добрый 32RGBA, я получаю работающую запись! Осталось понять, как определять, что девайс поддерживает wide-gamut. Бывают еще айпады с различными видами дисплея, и я подумал, что наверняка можно из системы достать ENUM типа дисплея. Покопавшись в документации, я его нашёл — это displayGamut [45] в UITraitCollection [46].

Отдав сборку тестерам, я получил от них приятные новости — всё работало без каких-либо лагов даже на старых девайсах!

В качестве заключения хочется вам сказать — занимайтесь 3D-графикой! У нас в приложении, для которого дополненная реальность не является основным кейсом использования, люди за выходные дня города прошли более 2000 километров, посмотрели более 3000 объектов и записали более 1000 видео с ними! Представьте, что вы сможете сделать, если займётесь этим сами.

Автор: elmon

Источник [47]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/razrabotka-pod-ios/302056

Ссылки в тексте:

[1] нод: https://developer.apple.com/documentation/scenekit/scnnode

[2] материалы: https://developer.apple.com/documentation/scenekit/scnmaterial/

[3] свойств: https://developer.apple.com/documentation/scenekit/scnmaterialproperty/

[4] CALayer: https://developer.apple.com/documentation/quartzcore/calayer

[5] AVPlayer: https://developer.apple.com/documentation/avfoundation/avplayer/

[6] SKScene: https://developer.apple.com/documentation/spritekit/skscene/

[7] источники освещения: https://developer.apple.com/documentation/scenekit/scnlight

[8] типов освещения: https://developer.apple.com/documentation/scenekit/scnlight/lighttype

[9] видов теней: https://developer.apple.com/documentation/scenekit/scnshadowmode

[10] камеру: https://developer.apple.com/documentation/scenekit/scncamera

[11] действия, описанные на языке JavaScript: https://developer.apple.com/documentation/scenekit/scnaction/1523984-javascriptaction

[12] стандартным методом: https://developer.apple.com/documentation/scenekit/scnscene/1523355-init

[13] конструктор сцены из URL: https://developer.apple.com/documentation/scenekit/scnscene/1522660-init

[14] параметры импорта: https://developer.apple.com/documentation/scenekit/scnscenesource/loadingoption

[15] конструктор с MDLAsset: https://developer.apple.com/documentation/scenekit/scnscene/1419833-init

[16] MDLAsset: https://developer.apple.com/documentation/modelio/mdlasset/

[17] SCNReferenceNode: https://developer.apple.com/documentation/scenekit/scnreferencenode

[18] SCNReplicatorConstraint: https://developer.apple.com/documentation/scenekit/scnreplicatorconstraint

[19] Сила: https://developer.apple.com/documentation/scenekit/scnconstraint/1468692-influencefactor

[20] Инкрементальность: https://developer.apple.com/documentation/scenekit/scnconstraint/2867541-isincremental

[21] SCNBillboardConstraint: https://developer.apple.com/documentation/scenekit/scnbillboardconstraint

[22] Look At Constraint: https://developer.apple.com/documentation/scenekit/scnlookatconstraint

[23] SCNDistanceConstraint: https://developer.apple.com/documentation/scenekit/scndistanceconstraint

[24] SCNSliderConstraint: https://developer.apple.com/documentation/scenekit/scnsliderconstraint

[25] SCNIKConstraint: https://developer.apple.com/documentation/scenekit/scnikconstraint

[26] displacement: https://developer.apple.com/documentation/scenekit/scnmaterial/2867516-displacement

[27] количеством сегментов: https://developer.apple.com/documentation/scenekit/scnplane/1523991-widthsegmentcount

[28] SKScene: https://developer.apple.com/documentation/spritekit/skscene

[29] SKVideoNode: https://developer.apple.com/documentation/spritekit/skvideonode

[30] Parallax Mapping: https://ru.wikipedia.org/wiki/Parallax_mapping

[31] Mobius: https://mobiusconf.com/talks/nch5tml8wg6scacc2ss88/

[32] ReplayKit: https://developer.apple.com/documentation/replaykit

[33] AVAssetWriter: https://developer.apple.com/documentation/avfoundation/avassetwriter

[34] AVAssetWriterInput: https://developer.apple.com/documentation/avfoundation/avassetwriterinput

[35] AVAssetWriterPixelBufferAdaptor: https://developer.apple.com/documentation/avfoundation/avassetwriterinputpixelbufferadaptor

[36] пиксельбуфер: https://developer.apple.com/documentation/corevideo/cvpixelbuffer-q2e

[37] .snapshot(): https://developer.apple.com/documentation/scenekit/scnview/1524031-snapshot

[38] CAMetalDrawable: https://developer.apple.com/documentation/quartzcore/cametaldrawable

[39] layer: https://developer.apple.com/documentation/uikit/uiview/1622436-layer

[40] CAMetalLayer: https://developer.apple.com/documentation/quartzcore/cametallayer

[41] deviceColorSpace: https://developer.apple.com/documentation/coregraphics/1408837-cgcolorspacecreatedevicergb

[42] частоиспользуемыми: https://developer.apple.com/documentation/corevideo/kcvpixelformattype_32bgra

[43] сломался: https://github.com/svtek/SceneKitVideoRecorder/issues/3

[44] kCVPixelFormatType_30RGBLEPackedWideGamut: https://developer.apple.com/documentation/corevideo/1563591-pixel_format_identifiers/kcvpixelformattype_30rgblepackedwidegamut

[45] displayGamut: https://developer.apple.com/documentation/uikit/uitraitcollection/1771749-displaygamut

[46] UITraitCollection: https://developer.apple.com/documentation/uikit/uitraitcollection

[47] Источник: https://habr.com/post/431880/?utm_campaign=431880