Doom of SceneKit
I’m too young to die.
SceneKit — это высокоуровневый фреймвок для 3D графики, который поможет вам создавать анимированные 3D сцены и эффекты в Ваших приложениях. Он включает в себя физический движок, генератор частиц и набор простых действий для 3D объектов, которые позволят вам описать вашу сцену в терминах контента — геометрии, материалов, освещения, камер — и анимировать её через описание изменений для этих объектов.
— Apple
Сегодня мы внимательным, немного суровым взглядом посмотрим на SceneKit, но, для начала, обратимся к основам, и посмотрим, что представляет из себя 3D сцена, и что нужно сделать, чтобы её создать.
Для начала, нужно создать основную структуру сцены, которая состоит из нод, или узлов сцены. Каждая нода может содержать в себе как геометрию, так и другие ноды. Геометрия может быть простой, вроде шара, куба, или пирамиды, так и более сложной, созданной во внешних редакторах.
Затем, для этой геометрии необходимо задать материалы, которые будут определять базовое представление объектов. Каждый материал сам задаёт свою модель освещения, и, в зависимости от неё, использует различный набор свойств. Каждое такое свойство обычно представляет из себя цвет или текстуру, но, помимо этих частоиспользуемых вариантов, есть ещё возможность использовать CALayer, AVPlayer, и SKScene.
После этого, необходимо добавить источники освещения, которые определяют то, насколько хорошо видны объекты в той или иной части сцены. Они, по аналогии с геометрией, должны лежать внутри какой-нибудь ноды. SceneKit поддерживает много разных типов освещения, а так же несколько видов теней.
Затем нужно создать камеру (и положить её в отдельную ноду) и задать для неё основные параметры. Их довольно много, но, с помощью них можно создавать крутые эффекты. Из коробки поддерживается эффект бокэ (или эффект размытия), HDR с адаптацией, свечение, SSAO и модификации оттенка/насыщенности.
И, наконец, SceneKit включает в себя простой набор действий для 3D объектов, которые позволяют задать изменения сцены во времени. Так же сценкит поддерживает действия, описанные на языке JavaScript, но это тема для отдельной статьи.
Помимо графики, главными фишками SceneKit’а являются генератор частиц и продвинутый физический движок, который позволяет задавать реальные физические свойства как обычным объектам, так и с частицам из генератора.
Про все эти фишки написано большое количество туториалов, которые рассказывают про них во всех подробностях. Но в процессе разработки мы эти возможности практически не использовали…
Hey, not too rough
Однажды, я написал модель освещения для 3D-игр лучше реального солнечного света, дающую приемлемую FPS на Nvidia 8800, но я решил не выпускать движок в свет, так как Бог мне симпатичен, и я не хочу показывать его некомпетентность в этом вопросе.
— Джон Кармак
Подробное изучение мы начнем с довольно простой задачи, которая возникает практически у всех, работающих со SceneKit’ом довольно серьёзно: как загрузить модель со сложной геометрией и подключенными материалами, освещением, да еще и с анимациями?
Есть несколько способов, и все они имеют свои плюсы и минусы:
- SCNScene(named:) — получает сцену из бандла;
- SCNScene(url:options:) — загружает сцену по url;
- SCNScene(mdlAsset:) — конвертирует сцену из разных форматов;
- SCNReferenceNode(url:) — лениво загружает сцену.
Получаем сцену из бандла
Можно воспользоваться стандартным методом: положить нашу модель в .dae или .scn формате в бандл .scnassets, и загрузить её оттуда, по аналогии с UIImage(named:).
Но что делать, если вы хотите сами контролировать обновление моделей, не выпуская обновление в AppStore, каждый раз, когда вам нужно поменять пару текстур? Или вы хотите поддержать созданные пользователями карты и модели? Или даже вы просто не хотите увеличивать размер приложения, так как 3D графика не является вашей основной функциональностью?
Загружаем сцену по url
Можно использовать конструктор сцены из URL .scn файла. Этот способ поддерживает загрузку не только из файловой системы, но и из сети, но, в последнем случае, можно забыть о сжатии. Плюс, вам потребуется заранее сконвертировать модель в формат scn. Можно, конечно, использовать и dae, но с ним приходит набор ограничений, например, отсутствие physically based rendering’а.
Главный плюс этого способа — он позволяет гибко настраивать параметры импорта. Можно, например, модифицировать жизненный цикл анимаций, и заставить их бесконечно повторяться. Можно явно указать место, откуда загружать внешние ресурсы, вроде текстур, можно конвертировать ориентацию и масштаб сцены, создавать отсутствующие нормали для геометрии, смержить всю геометрию сцены в одну большую ноду, или отбросить все не соответствующие стандарту формата элементы сцены.
Конвертируем сцену из разных форматов
Третий вариант — использовать конструктор с MDLAsset. То есть, сначала мы создаем MDLAsset, доступный в фреймворке ModelIO, и затем передаём его в конструктор для сцены.
Этот вариант хорош тем, что умеет загружать много различных форматов. Официально MDLAsset умеет загружать obj, ply, stl и usd форматы, но, прогнав список всех возможных форматов хоть как-то связанных с компьютерной графикой, я нашёл еще 4: 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’е есть свой набор констрейнтов. И хотя они не такие гибкие, как констрейнты в UIKit’е, с помощью них всё равно можно сделать много интересного.
Начнём с простого констрейнта — SCNReplicatorConstraint. Всё, что он делает — это дублирует позицию, поворот и размер другого объекта c дополнительными оффсетами. Как и у всех остальных констрейнтов, у него можно менять силу, и выставлять флаг инкрементальности, и лучше всего эти параметры можно показать на этом констрейнте.
Сила влияет на то, с каким коэффициентом трансформация применяется к объекту. И раз положение объекта-цели меняется каждый кадр — шадоу объект приближается к нему на одну десятую от разницы расстояний, и из-за этого появляется эффект запаздывания.
Инкрементальность же влияет на то, отменяется ли констрейнт после рендеринга. Если его выключить — то он на каждом кадре конестрейнт применяется перед рендерингом, и после рендеринга отменяется, и так повторяется каждый кадр. В результате, комбинируя эти два параметра можно получить довольно интересный эффект стрелки часов
Перейдём к более интересному констрейнту: так называемому биллборду.
Допустим, необходимо, чтобы некоторый объект всегда находился к нам «лицом». Для этого, нужно всего лишь использовать SCNBillboardConstraint, и указать, вокруг каких осей объект может поворачиваться, и, дальше, перед просчётом каждого кадра (после шага с физикой) позиции и ориентации всех объектов будут обновляться, чтобы удовлетворить всем констрейнтам.
Тут же можно упомянуть Look At Constraint: он аналогичен Billboard’у, только вместо текущей камеры, объект можно поставить лицом к любому другому объекту сцены.
Что можно сделать с их помощью? Чаще всего, конечно же, эти констрейнты применяются для рисования деревьев, или мелких объектов. Также с их помощью создают спецеффекты, вроде огня или взрыва, и, к тому же, с их помощью можно заставить камеру следить за объектом на сцене.
SCNDistanceConstraint позволяет задать минимальное и/или максимальное расстояние до позиции другого объекта. И да, с его помощью можно сделать змейку:) Этот констрейнт тоже можно использовать для привязки камеры к персонажу, хотя, положение камеры обычно задаётся более сложно, и одними констрейнтами его сложно описать. Тот же эффект можно достичь за счёт добавления пружины в физическом движке, но эту пружину можно дополнить констрейнтом на случай, если нужно избежать проблем с излишним растягиванием/сжатием пружины.
Многие видели, как в каком-нибудь Hitman’е, Fallout’е или Skyrim’е, бывает такое, что ты тащишь тело за собой, и оно задевает препятствие, и тело внезапно начинает вести себя как будто в него вселился демон. Так вот, этот констрейнт помог бы избежать таких багов.
SCNSliderConstraint позволяет задать минимальное расстояние между заданным объектом и физическими телами с подходящей маской коллизий. Довольно забавный констрейнт, но, опять же, его стараются симулировать с помощью физического взаимодействия. Основная идея — задать радиус мертвой зоны с физическими телами для объекта, который не имеет физического тела.
SCNIKConstraint. Самый интересный, но и самый сложный констрейнт, который использует так называемую инверсную кинематику, которая итеративно пытается приблизить положение ноды, к которому вы применяете этот констрейнт к необходимой точке, используя цепочку родительских узлов. По сути, он позволяет не думать о том, в каком положении должны находиться плечо и предплечье, а просто задать положение кисти руки и возможные углы поворота связующих узлов, а остальное посчитается за вас. Основной недостаток этого констрейнта — он позволяет лишь задать положение кисти руки, но не её ориентацию, да и ограничения на углы можно делать глобальное, без разбивки по осям.
Итак, мы подробно познакомились с констрейнтами, и что они умеют делать. Давайте продолжим изучать интересные эффекты, и разберемся с эффектом теней.
Казалось бы, что может быть проще в движке, который поддерживает тени, чем создание теней? Но, иногда, тени нужно отбросить на полностью прозрачную плоскость. Это очень полезно в ARKit’е, так как за плоскостью отображается изображение камеры, а тень должна куда-то отбрасываться. Трюк оказывается довольно прост: нужно сначала включить отложенные тени и отключить запись во все компоненты у плоскости во вкладке материала, и тень продолжит накладываться на неё. Единственная проблема — эта плоскость будет перекрывать объекты находящиеся за ней.
Но тени — не единственный слабо изученный эффект в сценките. Давайте теперь разберемся с зеркалами.
Все, кто игрался со сценкитом, наверняка знают о 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, и, которые, к тому же, доступны в визуальном редакторе, что позволяет видеть видеть изменения в модификаторе в реальном времени.
С помощью них можно создавать сложнейшие визуальные эффекты. Вот, например, парочка самых известных эффектов: мех и параллакс мэппинг.
Что ещё интереснее, никто не мешает полностью выкинуть результаты их работы, и написать свой рендерер. Например, можно попробовать реализовать Ray Casting в шейдерах. И всё это работает достаточно быстро, чтобы обеспечить 30 fps даже на таких сложных вычислениях. Но это тема для отдельного доклада. Приходите на Mobius!
Nightmare!
Я не люблю моргать, т. к. закрытые веки резко нагружают GPU для BDPT из-за недостатка освещения.
— Джон Кармак
Итак, у нас есть куча объектов с классными эффектами. Теперь осталось научиться их записывать. Для этого перейдём к более сложной теме: как мы научились записывать видео напрямую из SceneKit’a без внешнего UI, и как мы оптимизировали эту запись в десятки раз.
Давайте сначала обратимся к самому простому решению: ReplayKit, и выясним, почему оно не подходит. Вообще говоря, это решение позволяет в несколько строк кода создать запись экрана, и сохранить её через системное превью. Но. У него есть большой минус — он записывает всё, весь UI, в том числе и все кнопки на экране. Это было первое наше решение, но, по очевидным причинам, его в продакшн пускать было нельзя, так как этим видео должны были делиться пользователи, и делиться не с системного превью.
Итак, мы оказались в ситуации, когда решение нужно было написать с нуля. Совсем с нуля. Итак, давайте посмотрим, каким образом в айос можно создать своё видео, и записать туда свои кадры. Всё довольно просто:
Нужно создать сущность, которая будет записывать файлы — AVAssetWriter, добавить в неё видеопоток — AVAssetWriterInput, и создать для этого потока адаптер, который будет конвертировать наш пиксельбуфер в необходимый потоку формат — AVAssetWriterPixelBufferAdaptor.
На всякий случай напоминаю, что пиксельбуффер — это сущность, которая представляет собой кусок памяти, в котором каким-то образом записаны данные для пикселей. По сути — низкоуровневое представление картинки.
Но… Как получить этот пиксельбуффер? Решение простое — у SCNView есть замечательная функция .snapshot(), которая возвращает UIImage, и нам всего лишь нужно из этого UIImage создать пиксельбуффер.
Мы всего лишь аллоцируем место в памяти, описываем, какой у этих пикселей формат, блокируем буфер на изменение, получаем адрес этой памяти, создаём контекст по полученному адресу, где описываем, как пиксели упакованы, сколько в картинке линий и какое цветовое пространство мы используем. Затем, копируем туда пиксели из UIImage, зная конечный формат, и снимаем блокировку на изменение.
Теперь нужно это делать каждый кадр. Для этого мы создаеём дисплейлинк, который будет на каждый кадр вызывать коллбек, в котором мы и будем вызывать метод snapshot, и создавать из картинки пиксель буффер. Всё просто!
А вот и нет. Такое решение даже на мощных телефонах вызывает жуткие лаги и просадки фпс. Давайте займёмся оптимизацией.
Допустим, нам не нужно 60 фпс. И даже мы будем довольны 25. Но как это проще всего сделать? Конечно, просто вынести всё это на фоновый поток, тем более что по утверждениям разработчиков, эта функция потокобезопасна.
Хм, лагать стало меньше, но видео перестало записываться…
Всё просто. Как говорится, если у тебя есть проблема, и ты её будешь решать при помощи нескольких потоков — у тебя окажется 2 проблемы.
Если попытаться записать пиксельбуфер с таймстемпом, ниже чем у последнего записанного — то всё видео окажется невалидным.
Давайте тогда не записывать новый буфер до тех пор, пока предыдущая запись не закончится.
Хм, стало значительно лучше. Но всё равно, почему лаги появились изначально?
Оказывается, функция .snapshot(), с помощью которой мы получаем изображение с экрана, на каждый вызов создаёт новый рендерер, рисует кадр с нуля, и возвращает его, а не то изображение, которое присутствует на экране. Это приводит к забавным эффектам, например, физическая симуляция происходит в 2 раза быстрее.
Но погодите. Зачем мы каждый раз пытаемся отрендерить новый кадр? Наверняка, где-то можно найти тот буфер, который выводится на экран. И действительно, доступ к такому буферу есть, но он весьма нетривиален, и для этого нам нужно из Metal’а получить CAMetalDrawable.
К сожалению, напрямую из SCNView добраться до Metal’а не так просто, по довольно простой причине — в SceneKit’е тип API можно выбрать самому, но если заглянуть под капот и посмотреть на layer, то можно увидеть, что в качестве него выступает, в случае с Metal’ом, — CAMetalLayer.
Но и тут нас ждёт неудача: в CAMetalLayer’е единственный способ взаимодействовать с представлением — это функция nextDrawable, которая возвращает не занятый CAMetalDrawable. Подразумевается, что вы в него запишете данные, и вызовете у него функцию present, которая и отобразит его на экране.
Решение, на самом деле, существует. Дело в том, что после исчезания с экрана не деаллоцируется, а лишь помещается обратно в пул. Действительно, зачем каждый раз выделять память, если достаточно 2х-3х буферов: один, который показан на экране, второй для рендеринга, и третий, например, для постпроцессинга, если он у вас есть.
Получается, после отображения буфера, данные из него никуда не пропадают, и к ним можно спокойно и безопасно получить доступ.
И, если мы в наследнике начнём на каждый вызов nextDrawable() сохранять его, мы получим почти то, что нам нужно. Проблема в том, что сохраненный CAMetalDrawable — это тот, в который прямо сейчас рисуется изображение.
Прыжок к реальному решению очень прост — мы сохраняем и текущий дроуабл, и предыдущий.
И вот оно, готово, прямой доступ к памяти через CAMetalDrawable.
Итак, теперь мы не создаём контекст и рисуем UIImage в нём, а копируем один кусок памяти в другой. Возникает вопрос: а как же формат пикселей?..
А он не совпадает с deviceColorSpace… И не совпадает с частоиспользуемыми цветовыми пространствами…
Это ровно тот пункт, на котором сломался автор одного из публичных подов, который выполняет ту же задачу. Все остальные — даже до сюда не добрались.
Что же, все эти трюки ради жутковатого фильтра?
Ну уж нет! В статье про ARKit можно найти упоминание того, что изображение с камеры использует нестандартное цветовое пространство, а расширенное, и даже представило матрицу трансформации цветового пространства. Но зачем заниматься трансформацией, если можно попробовать записать прямо в этом формате. Осталось узнать — какой это формат из 60 доступных…
И тут я занялся перебором, и записывал по 3 видео в разные потоки с разными форматами, сменяя их при каждой записи.
В результате, примерно на сороковом формате мы получаем его название. Оказывается, это ни кто иной, как kCVPixelFormatType_30RGBLEPackedWideGamut. Как же я до этого не догадался?
Но моя радость продолжалась до первого тестера. У меня не было слов. Как? Я же только что потратил кучу времени на поиск правильного формата. Хорошо, что проблема локализовалась быстро — баг воспроизводился стабильно и только на 6s и 6s plus. Практически сразу после этого я вспомнил, что дисплеи с поддержкой wide gamut начали ставить только с 7х айфонов.
Поменяв WideGamut на старый добрый 32RGBA я получаю работающую запись! Осталось понять, как определять, что девайс поддерживает wide gamut, так как бывают еще айпады с различными видами дисплея, и я подумал, что наверняка можно из системы достать энам типа дисплея. Покопавшись в документации я его нашёл — это displayGamut в UITraitCollection’е.
Отдав сборку тестерам, я получил от них приятные новости — всё работало без каких либо лагов даже на старых девайсах!
В качестве заключения, хочется вам сказать — занимайтесь 3D графикой! У нас в приложении, для которого дополненная реальность не является основным кейсом использования, люди за выходные дня города прошли более 2000 километров, посмотрели более 3 тысяч объектов, и записали более 1000 видео с ними! Представьте себе, что вы сможете сделать, если займётесь этим сами.