Pull to refresh
623.1
Яндекс
Как мы делаем Яндекс

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

Reading time16 min
Views6.8K

— I’m too young to die.


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



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


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


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


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


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


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


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


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


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


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


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


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


Помимо графики, главными фишками 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-файла. Этот способ поддерживает загрузку не только из файловой системы, но и из сети, но в последнем случае можно забыть о сжатии. Плюс вам потребуется заранее сконвертировать модель в формат scn. Можно, конечно, использовать и dae, но с ним приходит набор ограничений. Например — отсутствие physically based-рендеринга.


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


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


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


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


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


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


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


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


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


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


В итоге мы остановились на первом варианте, так как нам было удобнее всего работать в формате 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. Всё, что он делает, это дублирует позицию, поворот и размер другого объекта c дополнительными оффсетами. Как и у всех остальных констрейнтов, у него можно менять силу и выставлять флаг инкрементальности. Лучше всего оба параметра можно показать на этом констрейнте.


Уменьшили силу в 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 вашего материала и использовать материал на плоскости с достаточно большим количеством сегментов. Осталось понять, как же её туда положить.


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


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


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


Перед тем, как что-то модифицировать, надо понять, что именно мы модифицируем. Шейдер по определению — это программа для 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.


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


Но как получить этот пиксельбуфер? Решение простое. У SCNView есть замечательная функция .snapshot(), которая возвращает 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, зная конечный формат, и снимаем блокировку на изменение.



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



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



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



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


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


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



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



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



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


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


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


Но и тут нас ждёт неудача: в 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… И не совпадает с частоиспользуемыми цветовыми пространствами…


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



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


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



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


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



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


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


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


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

Tags:
Hubs:
Total votes 41: ↑39 and ↓2+37
Comments11

Articles

Information

Website
www.ya.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия