Хабрахабр

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

— I’m too young to die.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hey, not too rough

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hurt me plenty

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

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

И перед рендерингом кадра"/>
Констрейнты в SceneKit считаются сразу после физики. <img src="https://habrastorage.org/getpro/habr/post_images/1e3/c0e/ffd/1e3c0effdc9c72b19d6e3d0a19a13796.png" alt="Констрейнты в SceneKit считаются сразу после физики. И перед рендерингом кадра

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

SCNReplicatorConstraint
SCNReplicatorConstraint

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

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

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

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

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

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

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

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

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

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

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

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

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

SCNSliderConstraint
SCNSliderConstraint

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

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

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

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

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

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

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

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

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

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

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

Ultra-Violence

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

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

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

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

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

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

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

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

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

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

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

#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!

Nightmare!

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

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

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

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

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

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

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

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

var unsafePixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(NULL, self.pixelBufferPool, &unsafePixelBuffer) guard let pixelBuffer = maybePixelBuffer else 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, зная конечный формат, и снимаем блокировку на изменение. Мы всего лишь аллоцируем место в памяти, описываем, какой у этих пикселей формат, блокируем буфер на изменение, получаем адрес памяти, создаём контекст по полученному адресу, где описываем, как пиксели упакованы, сколько в картинке линий и какое цветовое пространство мы используем.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Прыжок к реальному решению очень прост — мы сохраняем как текущий 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… И не совпадает с частоиспользуемыми цветовыми пространствами…

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

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

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть