Как стать автором
Обновить

Трёхмерный мир на плоском экране: как отобразить банковскую 3D-карту в приложении на Android

Разработка мобильных приложенийРазработка под Android
Tutorial

Привет, меня зовут Дмитрий Гайдук, я Android-разработчик KODE. В 2018 году к нам пришёл новый заказчик — болгарский банк TBI. У нас был опыт разработки банковских приложений, и в TBI был знакомый функционал: заявки на кредит, платежи и переводы. Кроме реализации кода, вёрстки и сетевых запросов, заказчик попросил добавить трёхмерности и покрутить банковскую карту вокруг своей оси.

Хорошо, сказали сделать — значит, сделаем. Всё же век цифровых технологий, кругом 3D, дополненная реальность и много разных библиотек для реализации. Но не всё было так просто, как я думал. Рассказываю о трудностях, с которыми мы столкнулись, и как в итоге решили такую нестандартную и интересную задачу.

Примечание. TBI Bank — один из лидеров болгарского рынка. С 2018 года компания KODE помогает банку создать digital-инфраструктуру, чтобы перевести процессы выдачи кредита и обслуживания клиентов полностью в онлайн. Подробнее о кейсе на нашем сайте.


Часть 1. Задача и поиск решений

Задача: отобразить 3D-объект банковской карты с интерактивными возможностями. Объект представлен одним OBJ и двумя PNG-файлами для каждой стороны карты.

Финальный результат работы
Финальный результат работы

Начинаем искать библиотеку.

Критерии:

  • Минимум усилий на добавление 3D-карты, адекватное время на изучение и реализацию задачи. Так как мы занимаемся разработкой мобильного приложения, а не геймдевом, мы хотели обойтись без погружения в тонкости OpenGL.

  • Хорошо документированное API.

  • Возможность добавить библиотеку в обычную вёрстку вместе с остальным GUI и использовать её как обычный компонент View. Данный компонент нужно было добавить в RecyclerView.

  • Интерактивность. Возможность крутить карту жестами.

Нашли не так много вариантов:

  • Библитека min3d. Похоже, проект заброшен.

  • Фреймворк libGDX. Подойдет только для отображения карты, нельзя использовать совместно с остальным GUI.

  • Движок Filament. Посмотрел демо для Android — не совсем то, что нужно: слишком много специфичного кода для рендера, чтобы просто добавить 3D-карту.

  • Библиотека ARCore для дополнительной реальности от Google. Построена на основе Filament.

Выбор пал на ARCore. Мы учитывали опыт разработчиков приложения Revolut: в статье Interactive 3D cards for Revolut Android app было описано использование этой библиотеки без дополненной реальности. Код выглядел несложным — кажется, то, что нужно.


Часть 2. Welcome to Android

Читаем статьи, как всё хорошо в Android, затем добавляем банковскую карту в приложение и радуемся. Эх, мечты!

Сначала коротко расскажу о том, как добавить 3D-объект на экран, используя библиотеку ARCore. Более подробную инструкцию можно найти в интернете.

  1. Добавляем файлы 3D-объекта в проект. В нашем случае это OBJ и PNG для каждой стороны карты.

  2. В конфигурации build.gradle добавляем плагин Sceneform

apply plugin: 'com.google.ar.sceneform.plugin'
sceneform.asset('card/card.obj',
        'default',
        'card/card.sfa',
        'src/main/assets/card')

3. Консольная команда для генерации SFB-файлов для ScenefForm:

./gradlew compileSceneformAssets
4. Добавляем отображение 3D-объекта на экране
ModelRenderable.builder()
  .setSource(this.context, Uri.parse("card.sfb"))
  .setRegistryId("card.sfb")
  .build()
  .thenAccept { model: ModelRenderable ->
    // Добавление ModelRenderable на сцену com.google.ar.sceneform.SceneView
  }
  .exceptionally { e: Throwable? ->
    e?.printStackTrace()
    null
  }

Немного кода, и трёхмерный мир на экране вашего устройства!


Далее я рассмотрю проблемы, выявленные во время разработки с ARCore без дополненной реальности. Многие решения не идеальны. Нам пришлось прибегнуть к некому workaround: в документации нет информации, как это сделать правильно с помощью API.

Проблема 1. Источник света

Освещение окружающей среды задается по умолчанию, и это нельзя изменить.

Официальный ответ:

Sceneform adds an environment light based on an image (IBL/image-based lighting). This environment light cannot be customized currently, hopefully in a future version.

Перевод

Sceneform добавляет освещение на основе конкретного изображения (IBL). Сейчас его нельзя настроить, но, надеюсь, будет возможно в будущем.

На карте отражаются объекты, которых не должно быть

Нам нужен только источник света без каких-либо объектов из окружающего мира. В классе com.google.ar.sceneform.Scene освещение окружающей среды задаётся таким образом:

LightProbe.builder()
 .setAssetName("custom light probe")
 .setSource(this, R.raw.custom_light_probe)
 .build().thenAccept {
    scene.lightProbe = it
 }

Где custom_light_probe — это файл .sfb. По умолчанию используется R.raw.sceneform_default_light_probe.

В этом же классе можно увидеть код создания объекта Light Probe
   private void setupLightProbe(SceneView var1) {
        Preconditions.checkNotNull(var1, "Parameter \"view\" was null.");
        int var2;
        if ((var2 = LoadHelper.rawResourceNameToIdentifier(var1.getContext(), "sceneform_default_light_probe")) == 0) {
            Log.w(TAG, "Unable to find the default Light Probe. The scene will not be lit unless a light probe is set.");
        } else {
            try {
                LightProbe.builder().setSource(var1.getContext(), var2).setAssetName("small_empty_house_2k").build().thenAccept(new n(this)).exceptionally(m.a);
            }
    // ...
}

Возможно, файл sceneform_default_light_probe.sfb создан на основе small_empty_house_2k.exr.

Изображение внутри EXR-файла, которое видно в отражении карты
Изображение внутри EXR-файла, которое видно в отражении карты

Как создать собственный файл .sfb из .exr — неизвестно. Есть утилита cmgen для 3-движка Filament, которая позволяет получить Image Based Lighting из файла .exr. Но нужно собирать из исходников, и не подходит из-за формата: нам нужен именно .sfb. Команда Gradle — compileSceneformAssets — тоже это делать не умеет.

Решение

Шаг 1. Убираем источник света от LightProbe и используем только источник от Scene.sunlight:

scene.lightProbe.dispose()
scene.sunlight?.isEnabled = true
scene.setUseHdrLightEstimate(true)
Результат

Не совсем то, что нужно: получается темноватое изображение.

Шаг 2. Пробуем добавить ещё один источник света.

В поле Scene.sunlight находится объект класса com.google.ar.sceneform.Sun, который наследуется от Node. Получается, нужно добавить на сцену ещё один Node с определённой конфигурацией. С использованием HDR должно выглядеть круто!

Код
scene.lightProbe.dispose()
scene.sunlight?.isEnabled = false
scene.setUseHdrLightEstimate(true)

val light = Node()
light.light = Light.builder(Light.Type.DIRECTIONAL)
  .setColor(Color(1f, 1f, 1f))
  .setIntensity(200f)
  .setShadowCastingEnabled(true)
  .build()

light.worldPosition = Vector3(1f, 1f, 1f)
light.setLookDirection(Vector3(0f, 0f, 0f))
sceneView.scene.addChild(light)
Результат

Картинка стала ярче, но нам нужно более «охватывающее» освещение, без резких тёмных сторон при повороте карты. В процессе проверки разных вариантов освещения выяснилось, что через Node нельзя добавить несколько источников света.

Шаг 3. Сводим к минимуму отражение внешнего мира.

Такое решение было принято, так как мы не можем обойтись без источника света LightProbe и изменить картинку по умолчанию. Чтобы убрать отражение, нужно повернуть «освещение окружающей среды»:

val lightProbe = scene.lightProbe
lightProbe.rotation = Quaternion(Vector3.up(), 78F)
lightProbe.intensity = 2.5f
scene.lightProbe = lightProbe

Проблема 2. Материал объекта

Далее нам нужно настроить материал для сторон карты и сделать отражение немного размытым. Для этого открываем файл .mtl и редактируем нужные параметры. Как устроен этот файл и какие параметры доступны можно посмотреть в Wiki, а всю исчерпывающую информацию ищите здесь.

Для уменьшения «зеркальности» материала нужно изменить значение для Ns — коэффициента зеркального отражения. Подбираем остальные параметры:

Ns 40.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2

Теперь карта выглядит практически так, как нужно.

Результат

Проблема 3. Цвет фона

В тёмной теме мы заметили, что фон области просмотра 3D отличается от остального приложения: у 3D-сцены он немного темнее. Сделать фон прозрачным не получилось. Если выставить цвет color/transparent или null, то фон становится чёрным.

Вёрстка с цветом для тёмной темы, цвет общего фона и фона SceneView одинаковый.

Код
<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#333747"
    >
    <com.google.ar.sceneform.SceneView
        android:id="@+id/sceneView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="#333747"
        android:layout_margin="16dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />
</androidx.constraintlayout.widget.ConstraintLayout>
Как выглядит карта на данном этапе

Выяснилось, что это из-за специфической логики работы библиотеки. Конвертирование цвета происходит в классе com.google.ar.sceneform.rendering.Color:

Код
public void set(@ColorInt int argb) {
    int red = android.graphics.Color.red(argb);
    int green = android.graphics.Color.green(argb);
    int blue = android.graphics.Color.blue(argb);
    int alpha = android.graphics.Color.alpha(argb);
    float[] linearColor = Colors.toLinear(RgbType.SRGB, (float)red * 0.003921569F, (float)green * 0.003921569F, (float)blue * 0.003921569F);
    this.r = linearColor[0];
    this.g = linearColor[1];
    this.b = linearColor[2];
    this.a = (float)alpha * 0.003921569F;
}

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

Решение

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

Проблема 4. Определение поддержки 3D на устройстве

Библиотека ARCore не работает на Android ниже 7 версии. Но не всё так просто. На некоторых устройствах приложение падает и на Android 7. Нужно индивидуально определять: есть поддержка или нет.

Решение

Создаём объект SceneView:

fun isSceneViewAvailable(context: Context): Boolean {
  return try {
	SceneView(context).destroy()
	true
  } catch (e: Throwable) {
	false
  }
}

Проблема 5. Асинхронная инициализация SceneView

В ходе тестирования обнаружилась ещё одна ошибка: если многократно открывать экран с 3D-картой, в какой-то момент она может не отобразиться.

Выяснилось, что инициализация объекта Scene происходит асинхронно. То есть после создания Scene поле lightProbe какое-то время имеет значение null. Более того, если попытаться получить значение Scene.lightProbe до того, как произошла инициализация, то получим исключение, и приложение упадёт.

Реализация метода getLightProbe() в библиотеке ARCore:

public LightProbe getLightProbe() {
    if (this.lightProbe == null) {
        throw new IllegalStateException("Scene's lightProbe must not be null.");
    } else {
        return this.lightProbe;
    }
}

Чтобы настроить освещение и добавить объект на экран, нужно дождаться, когда библиотека инициализируется. Но публичных методов для этого нет.

Решение

Создаём цикличную проверку, которая будет регулярно определять, всё ли инициализировалось. И только после этого будет начинаться работа с библиотекой.

Код
val checkLightProbeIntervalMilliseconds = 50L
val disposable = Observable.interval(checkLightProbeIntervalMilliseconds, TimeUnit.MILLISECONDS)
  .map {
    try {
      card_info_scene_view?.scene?.lightProbe == null
    } catch (e: IllegalStateException) {
      true
    }
  }
  .takeUntil { lightProbeIsNull -> !lightProbeIsNull }
  .filter { lightProbeIsNull -> !lightProbeIsNull }
  .subscribeOn(Schedulers.io())
  .observeOn(AndroidSchedulers.mainThread())
  .subscribe(
    {
      setupScene()
    },
    { e ->
      e.printStackTrace()
    }
  )

Часть 3. Наслаждаемся результатом и подводим итоги

Запускаем приложение — карта крутится. Классно!

Был получен хороший опыт работы с 3D в Android, потому что нам удалось реализовать поставленную задачу с помощью небольшого объёма кода: чуть более 200 строк на отображение карты и добавление интерактивности.

Пример кода со всеми наработками — на GitHub.

Конечно, хотелось бы использовать более удобный инструмент, чтобы можно было проще импортировать 3D-модель, редактировать основные параметры и сразу видеть результат. Библиотека ARCore всё-таки нацелена на использование для дополненной реальности, поэтому для других целей её использовать проблематично.

Подсмотрел, как это выглядит при разработке iOS-приложений

Довольно простой импорт модели, в IDE встроен 3D-редактор, можно настроить освещение и параметры материала. Будем надеяться, что и в Android Studio появится что-то подобное.

Редактор для 3D-моделей в Xcode
Редактор для 3D-моделей в Xcode

Возможно, стоит посмотреть в сторону Filament, на которой построена ARCore. Она позволит настраивать построение 3D-сцены более точно. А время на изучение Filament должно компенсировать время, потраченное на подводные камни ARCore.

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

Теги:androidkotlinразработка мобильных приложений3darcore
Хабы: Разработка мобильных приложений Разработка под Android
Всего голосов 3: ↑2 и ↓1 +1
Просмотры5.3K

Похожие публикации

Разработчик мобильных приложений Android (IoT, Умный дом)
от 120 000 до 180 000 ₽UnicornМожно удаленно
Android разработчик
от 70 000 ₽Magnum Hunt Executive SearchМосква
Android (Kotlin) / iOS (Swift) developer
от 100 000 ₽IntelliCodeНовосибирскМожно удаленно
Android-разработчик
от 160 000 до 260 000 ₽Цифровые решенияМоскваМожно удаленно
Программист Android
от 94 000 ₽ТатнефтьКазань

Лучшие публикации за сутки