Одна из запоминающихся частей приложения «Кошелёк» — 3D-изображение карт и купонов с источниками света, бликами на поверхности и возможностью вращения. На собеседованиях кандидаты часто спрашивают, как мы это реализовали, и так как тема многим интересна, расскажем об этом подробнее. Результат нашей работы не является сверхсложным самописным 3D-движком, но в целом что-то подобное редко встречается в бизнесовых приложениях. И если у вас есть задачи, подобные этой, но они кажутся непонятными — давайте продолжим.
Предыстория
Изначально в Кошельке 3D-рендер был написан, как кроссплатформенное решение, и использовал под капотом OpenGL. Данная реализация по-прежнему используется в Android-версии приложения.
Здесь стоит отметить, что в 3D был не только экран карты, но и список карт. Так как в перспективе мы планируем совместить список карт с возможностью показа предложений из каталога, решено было уйти от этого решения. Теперь для списка карт мы используем UICollectionView c кастомным layout’ом. Кстати, коллеги из Android-команды в свое время выступали на конференции с докладом по этой теме: «Приложение «Кошелёк»: Как мы оживляем карты».
В 2018 году на WWDC Apple объявила, что в следующих версиях iOS будет прекращена поддержка OpenGL, и рекомендовала всем переходить на Metal. Детальное сравнение двух технологий можно посмотреть в докладе Metal for OpenGL Developers с WWDC. К слову, несмотря на то, что OpenGL уже несколько лет, как deprecated, в iOS 14 он по-прежнему доступен для использования; понятно, что обновлений для него с тех пор нет. Предварительно оценив время и силы, которые необходимо потратить на перенос существующего решения, мы решили использовать новую технологию и попробовать написать MVP. Самым простым решением было использовать какой-нибудь готовый движок для работы с 3D, где будет поддержка как Metal, так и OpenGL. Это бы дало возможности настройки параметров через визуальный редактор, а также упростило работу, связанную с моделями, текстурами, камерой, освещением и анимациями. Но тянуть движок в проект ради «прямоугольника с красивой текстурой» — это слишком. Если взять для сравнения Unity, то это и увеличение размера приложения (судя по документации, минимальный размер приложения составляет 12 МБ, для нас это равно +20% к размеру приложения), и стоимость 200$ в год, и необходимость показывать лого на сплэш-скрине (решается покупкой более дорогой лицензии), и увеличенное время загрузки. А если смотреть в сторону open source решений (LibGDX, urho3d), то поддержки Metal у них нет. И тут нам на помощь приходит нативное решение от Apple — SceneKit, лишенное недостатков, описанных выше (отсутствует влияние на размер приложения — если сравнивать пустые проекты, то импортирование SceneKit увеличило размер приложения на 1 КБ; бесплатность, визуальный редактор и прочее).
Получив довольно приемлемый результат в течение одного дня, мы решили двигаться с ним в продакшн.
SceneKit
SceneKit — высокоуровневый фреймворк, работающий поверх Metal или OpenGL, упрощающий работу с 3D-графикой и позволяющий легко добавлять анимации, работу с физикой, частицы и реалистичный рендеринг, основанный на физике. Самым интересным для нас является как раз последний пункт.
SceneKit поддерживает несколько концепций для визуализации материалов — это blinn, constant, lambert, phong и physically based. Подробнее про каждую из моделей можно почитать в документации. Но сравнение этих подходов хорошо видно на изображении:
Как видите, physically based тут — явный фаворит.
Physically based rendering (PBR) — концепция визуализации материалов, основанная на физических принципах. Подробно данная тема раскрыта в докладе Advances in SceneKit Rendering с WWDC. Если кратко, то суть данного подхода в том, что любая поверхность состоит из трёх составляющих: диффузной, металлической и неровности. Попробуем разобраться с каждой из составляющих на примере создания золотой поверхности:
- диффузная (diffuse) — определяет количество света, диффузно отраженного от поверхности. Рассеянный свет одинаково отражается во всех направлениях и поэтому не зависит от точки зрения.
Так как золото отражает однородный свет по всей поверхности, то наша диффузная текстура будет полностью жёлтой и выглядеть так:
- металлическая (metalness) — определяет, насколько поверхность схожа с металлом. Более низкие значения (тёмные цвета) приводят к тому, что материал больше похож на диэлектрическую поверхность. Более высокие значения (яркие цвета) делают поверхность более металлической, и, соответственно, более зеркальной.
Так как мы рисуем полностью золотую текстуру без примесей, а золото это металл, то и metalness текстура должна быть полностью белой.
- неровность (roughness) — определяет, насколько поверхность кажется гладкой. Более низкие значения (тёмные цвета) приводят к тому, что материал выглядит блестящим, с четко определёнными зеркальными бликами. Более высокие значения (яркие цвета) заставляют зеркальные блики расширяться, а диффузное свойство материала становится более световозвращающим.
Золото — практически идеально ровный материал, поэтому текстура неровности приближена к чёрному, а для создания дефектов на поверхности добавим более светлые участки:
Сложив вместе три составляющих, мы получим:
Для наглядности связь между metalness и roughness представлена ниже:
Еще один важный момент: данная технология доступна в SceneKit с iOS 10, но на практике выяснилось, что из-за перехода на вторую версию Metal итоговое изображение очень сильно отличается, и в этом случае нам приходится поддерживать две конфигурации. На тот момент минимальной поддерживаемой версией в приложении была iOS 9. А так как даже 3.8% пользователей на версиях с 9 по 10 по количеству для нас равнялись населению небольшого города (55 000), отказаться от них было тяжелым решением. Взвесив все за и против (в первую очередь это упрощение верстки, так как с iOS 11 началась эра экранов iPhone X и safe area, а также снижение нагрузки на тестирование), мы решили поднять минимальную версию до iOS 11.
Реализация
Основой SceneKit является сцена (SCNScene), поэтому первым делом необходимо добавить её. В документации сказано, что сцену и изображения текстур для оптимизации лучше сохранять в каталог с разрешением .scnassets. Мы можем добавить его через File > New > File > раздел Resource > SceneKit Catalog. В дальнейшем все файлы, связанные со SceneKit, будем помещать в него. Для создания сцены у нас есть два простых варианта: либо добавить её программно и из кода добавлять на неё объекты, либо же воспользоваться визуальным редактором, создав сцену через File > New > File > раздел Resource > SceneKit Scene File. Выберем второй вариант, в результате получим сцену с камерой.
Помимо перечисленных способов, можно загружать сцены из файла или по url.
Следующим шагом будет добавление 3D-модели на сцену. Можно воспользоваться готовыми примитивами:
Но вряд ли это то, что вам нужно. Можно, конечно, попробовать составить объект из примитивов, но текстурирование объектов SceneKit не поддерживает, нет возможности редактировать текстурные координаты, доступен только просмотр. Поэтому предусмотрена поддержка сторонних моделей, выгруженных из 3D-редакторов. Мы использовали Blender. Основным форматом для загрузки моделей в SceneKit является DAE (Digital Asset Exchange или Collada). При загрузке мы можем сразу же конвертировать его в SCN формат, или сделать это позже через Editor > Convert to SceneKit file format (.scn). Также доступна конвертация и в обратную сторону.
Следующим шагом необходимо добавить источники света на сцену — можно сделать это вручную, на выбор доступны несколько типов.
Есть и более интересный способ: добавить текстуру окружения и сгенерировать источники света автоматически на её основе. В этом случае источник света может быть любой формы. Наша выглядит вот так:
Добавляем текстуру в проект, а дальше в настройке сцены в разделе Background and Lighting используем в качестве Environment. При необходимости можно её же использовать в качестве фона, поместив в Background. Стоит отметить, что текстуры могут быть в HDR, а размеры текстуры должны соответствовать определенному формату. Для сферической текстуры (мы находимся в сфере), как у нас, соотношение ширины к высоте должно быть 2 к 1, для кубической (мы находимся в кубе) соотношение сторон должно быть 6 к 1. Детально про форматы можно почитать в документации, раздел Using Cube Map Textures.
В результате на карте появляется блик от источника света, а сама сцена выглядит теперь так:
Осталось добавить материал. Как было сказано выше, для PBR нам понадобится три варианта текстур: diffuse, metalness, roughness.
Так как мы хотели добиться «металлического» эффекта отражения на части карты, как от фольги, при отрисовке metalness текстуры эти участки делаем светлыми, чтобы приблизить поверхность к металлу. Основной же фон остается чёрным, так как основная поверхность карты — это пластик. Для roughness текстуры, наоборот, основную поверхность делаем светлой, чтобы придать эффект матовости при отражении, а выделенные участки затемняем, чтобы получить зеркальное отражение.
Переходим на вкладку настройки материала в Shading, выбираем Physically Based и устанавливаем текстуры. Одним из основных параметров у текстур является intensity. Свойство изменяется в диапазоне от 0.0 до 1.0 и в зависимости от типа текстуры приводит к разным результатам. Например, при уменьшении значения для текстуры нормалей (к этому вернёмся ниже) мы получаем сглаживание поверхности, для metalness и roughness текстур — общее затемнение. Таким образом, в случае необходимости мы можем сгладить общее влияние текстуры на конечный результат.
Обычно пластиковые карты сверху покрыты защитным слоем, и, если присмотреться, то на свету за счет этого появляются «переливающиеся точки». В идеале для достижения такого эффекта использовать многослойный материал, но SceneKit их не поддерживает. Мы пошли на хитрость и для получения похожего эффекта используем не совсем по назначению текстуру нормалей с низкой интенсивностью, которая представляет из себя случайно генерируемый шум:
На сцене видим итоговый результат наших манипуляций. Здесь стоит отметить, что то, что мы получили в редакторе сцены не совсем соответствует тому, что впоследствии будет на телефоне:
Теперь нужно вывести сцену на экран. Для этого используется объект SCNView, наследник UIView. Можем инициализировать его из кода, указав нашу сцену, либо просто добавив его на view у UIViewController через storyboard. Для дебага мы можем выставить свойство allowsCameraControl в true, чтобы иметь возможность управлять камерой сцены, и showStatistic для отображения FPS и количества полигонов на экране.
let sceneView = SCNView(frame: view.bounds, options: nil)
sceneView.scene = SCNScene(named: "SceneKitAsset.scnassets/Scene.scn")
sceneView.allowsCameraControl = true
sceneView.showsStatistics = true
view.addSubview(sceneView)
Собираем проект и получаем итоговый результат:
Apple Watch
Отдельный плюс использования SceneKit — это поддержка Apple Watch. Для отображения карты на часах необходимо проделать всего несколько шагов. Добавим нашу сцену и все материалы, связанные с ней, в Target extension Apple Watch.
Для вывода сцены вместо SCNView используется WKInterfaceSCNScene. Добавляем его в Storyboard на наш контроллер и создаем свойство:
@IBOutlet weak var sceneInterface: WKInterfaceSCNScene!
Работа с WKInterfaceSCNScene выглядит почти так же, как и SCNView, но за исключением пары моментов. Не поддерживается автоматическое управление камерой, поэтому для оживления нашей сцены добавляем бесконечную анимацию вращения карты. Также не поддерживаются текстуры окружения в HDR формате, и в этот раз для установки источников света воспользуемся свойством autoenablesDefaultLighting, с помощью которого на сцену автоматически будет добавлен всенаправленный источник света.
guard let scene = SCNScene(named: "SceneKitAsset.scnassets/Scene.scn") else {
return
}
// Take card and start forever rotation animation
if let card = scene.rootNode.childNode(withName: "Card", recursively: true) {
card.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0,
y: 2,
z: 0,
duration: 1)))
}
В результате получим:
Проблемы, с которыми мы столкнулись в продакшн версии
Так как теперь нам требовалось три текстуры, а не одна, и отрисовывать их под каждую карту было трудозатратно, мы решили пойти другим путём. В качестве metalness текстуры мы берем черно-белое изображение с высоким контрастом от diffuse текстуры, а затем делаем затемнение светлых участков. Потом с помощью свойства intensity дополнительно затемняем текстуру. Напомню, что чем более светлые цвета мы используем, тем поверхность ближе к металлу, а чем темнее — тем ближе к диэлектрику.
func grayscaleImage(image: UIImage) -> UIImage? {
guard let ciImage = CIImage(image: image) else {
return nil
}
let grayscale = ciImage.applyingFilter("CIPhotoEffectNoir", parameters: [:])
let black = grayscale.applyingFilter("CIToneCurve", parameters: [
"inputPoint0": CIVector(x: 0.0, y: 0.0),
"inputPoint4": CIVector(x: 1.0, y: 0.9)
])
return UIImage(ciImage: black)
}
В качестве roughness текстуры, так как поверхность почти всех карт однородная, используем подобранное числовое значение с чуть заниженной интенсивностью. Напомню, что чем цвет темнее, тем более блестящей выглядит поверхность.
Так как для нормалей используется текстура шума, чтобы не хранить её в проекте, генерируем ее программно.
func noise() -> UIImage? {
guard let randomGenerator = CIFilter(name: "CIPhotoEffectMono") else {
return nil
}
randomGenerator.setValue(CIFilter(name: "CIRandomGenerator")?.outputImage,
forKey: kCIInputImageKey)
randomGenerator.setDefaults()
if let ciImage = randomGenerator.outputImage {
let rect = CGRect(x: Int.random(in: 0...100),
y: 0,
width: 1_024,
height: 1_024)
if let ref = CIContext(options: nil).createCGImage(ciImage, from: rect) {
let image = UIImage(cgImage: ref)
return image
}
}
return nil
}
Помимо прочего, у нас была необходимость изменять направление источников света в зависимости от положения телефона. Здесь вроде бы всё просто: посчитали угол наклона устройства через CMMotionManager и повернули источники света. Но текстура окружения не поддерживает поворот, поэтому пришлось и камеру, и карту положить в отдельный объект, и уже его вращать. За счет этого создаётся впечатление смены направления источников света.
На самом деле в продакшне использование текстуры окружения принесло нам больше всего проблем. На версиях ниже iOS 12 сцена отказывалась рендериться с включённой текстурой окружения на части устройств (от iPhone 6 до iPhone X) при этом чёткой зависимости не прослеживалось. С выходом iOS 14 мы получили очень долгую загрузку, поэтому на этих версиях программно добавляем источники света. Результат уже, конечно, выглядит не так интересно.
Чтобы не фризить приложение, объекты можно загрузить в бэкграунде с помощью метода
prepareObjects:withCompletionHandler:, но здесь есть один важный момент. Несмотря на вызов completionHandler, так как ещё ни одного кадра не было отрисовано, на мгновение можно увидеть пустую сцену. Чтобы этого избежать, необходимо дождаться отрисовки в делегате SCNView — SCNSceneRenderDelegate в методе renderer(_:didRenderScene:atTime:)
Итоги
Как это часто бывает, Apple предоставила разработчикам довольно качественный инструмент для работы с 3D графикой. В результате, приложив минимум усилий, мы получили MVP, на основе которого можно строить продакшн решение.
Плюсы подхода:
- скорость разработки;
- минимальное изменение размера приложения;
- отсутствие сторонних зависимостей;
- легкая поддержка кода, нет необходимости долго и глубоко погружаться в область для внесения изменений;
- простота работы с камерой и освещением;
- поддержка анимаций;
- встроенный физический движок и работа с частицами;
- поддержка всей экосистемы Apple, в том числе и Apple Watch.
Минусы подхода:
- отсутствует кроссплатформенность;
- сложно что-то оптимизировать, использование Metal дало бы более широкие возможности в этом плане.
Видно, что плюсов у подхода значительно больше, чем минусов. Поэтому, если вам требуется реализовать что-то подобное и нет необходимости погружаться в низкоуровневые оптимизации (и не нужна кроссплатформенность), то SceneKit — довольно подходящее решение. Плюс, оно легко совместимо с UIKit и имеет высокую скорость разработки. Нам же реализация 3D-карты далась не так сложно, как может показаться на первый взгляд, но при этом она создаёт вау-эффект для неискушённого пользователя и является самой впечатляющей фичей приложения.
Кстати, о своих подходах к решению различных задач мы регулярно рассказываем в Telegram-канале.