Технологии дополненной реальности (Augmented Reality, AR) развиваются с первых экспериментов с шлемами в 1968 году и прогнозируются как один из быстрорастущих сегментов развития интерфейсов (особенно при появлении специализированных устройств, таких как Hololens, Xiaomi Smart Glasses и проекта с непонятной судьбой Google Glass). Не могли не заметить этот тренд и разработчики операционных систем для мобильных устройств, Apple выпустила свой набор инструментов ARKit, также как и Google создала набор библиотек ARCore. Особенно важно, что поддержка этих библиотек доступна на большом количестве устройств (для Android нужна версия 7.0 или новее, а это более 94% доступных устройств, при этом почти 90% из них поддерживают Depth API, необходимый для корректной работы алгоритмов размещения объектов виртуального мира в сложном окружении). В этой статье мы рассмотрим основные вопросы использования ARCore и размещения объектов виртуального мира над поверхностями реального.

Прежде всего нужно убедиться, что ваш телефон поддерживает ARCore, для этого необходимо установить Google Play Services for AR по ссылке. В любом случае для разработки можно использовать эмулятор. Для корректной работы на эмуляторе нужно установить этот пакет (версия для эмулятора).

При создании приложения необходимо указать минимальную версию SDK 24, а также необходимость использования AR и, если использование ARCore критично для основной функциональности приложения, также отметить его как Required, например так (в AndroidManifest.xml):

<uses-feature android:name="android.hardware.camera.ar" />

<application>
    <meta-data android:name="com.google.ar.core" android:value="required" />
</application>

Также в коде должны быть выполнены вызовы ArCoreApk.checkAvailability() для проверки доступности библиотеки и ArCoreApk.requestInstall() для перехода к установке библиотеки. Также в зависимости проекта нужно добавить библиотеку:

dependencies {
//...
  implementation("com.google.ar:core:1.36.0")
}

Добавим в onCreate в MainActivity проверку доступности AR:

        val availability = ArCoreApk.getInstance().checkAvailability(this)
        println("AR is supported: ${availability.isSupported}")

Если возвращается значение true, то мы можем использовать функциональность библиотеки иначе можно запросить установку ArCore:

        if (!availability.isSupported) {
            val result = ArCoreApk.getInstance().requestInstall(this, false)
            when (result) {
                ArCoreApk.InstallStatus.INSTALLED -> println("Do some work")
                ArCoreApk.InstallStatus.INSTALL_REQUESTED ->  println("Just wait for user actions")
            }
        }

Поскольку дополненная реальность использует камеру телефона, то нужно дополнительно объявить и запросить разрешение для камеры, добавим в AndroidManifest.xml

    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />

    <uses-permission android:name="android.permission.CAMERA"/>

И запросим runtime-разрешение для доступа к камере (CAMERA_REQUEST_CODE=1000):

when (PackageManager.PERMISSION_GRANTED) {
    ContextCompat.checkSelfPermission(
        this,
        Manifest.permission.CAMERA
    ) -> {
        println("Do some job")
    }
    else -> {
        requestPermissions(
            arrayOf(Manifest.permission.CAMERA),
            CAMERA_REQUEST_CODE
        )
    }
}

и также обработаем результат действия выдачи разрешения (если было запрошено):

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode== CAMERA_REQUEST_CODE) {
        if (grantResults.first() == PackageManager.PERMISSION_GRANTED) {
            println("Do some work with camera")
        }
    }
}

Все взаимодействие с библиотекой выполняется через сессию:

import com.google.ar.core.Config
import com.google.ar.core.Session

lateinit var session: Session

fun onCreate() {

    //...инициализация...
  
    session = Session(config)
    val config = Config(session)
    //здесь можно изменить конфигурацию
    //например setFocusMode устанавливает режим фокусировки
    //также можно управлять алгоритмами поиска поверхностей (setPlaneFindingMode),
    //включать поддержку режима глубины (setDepthMode), управлять способами обнаружения
    //источников освещения (setLightEstimationMode) и т.д.
    session.configure(config)
}

fun onDestroy() {
    session.close()
}

Для создания объектов 3D-сцены будут использоваться возможности OpenGL ES, для этого на сцену необходимо поместить View с типом GLSurfaceView (activity_main.xml):

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <android.opengl.GLSurfaceView
        android:id="@+id/surface"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    
</androidx.constraintlayout.widget.ConstraintLayout>

Выполним инициализацию контекста и присоединим класс для отрисовки сцены:

        val surface = findViewById<GLSurfaceView>(R.id.surface)
        surface.setRenderer(MainRenderer())
        surface.requestRender()

Сам класс MainRenderer будет наследоваться от android.opengl.GLSurfaceView.Renderer и определять следующие методы:

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        println("Surface created")
        //здесь нужно выполнить инициализацию контекста
    }

    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        println("Surface changed")
        //здесь можно обновить контекст (например, размеры сцены)
    }

    override fun onDrawFrame(gl: GL10?) {
        println("Draw frame")
        val frame = session.update()
        //... логика отрисовки
    }

Здесь наиболее важным является объект frame, который позволяет как получить доступ к актуальному изображению с камеры и получить результаты распознавания источников света, ключевых точек, поверхностей или поз, например:

  • acquireCameraImage() извлекает текущее изображение с камеры;

  • acquireRawDepthImage16Bits()получить карту глубины для изображения;

  • getLightEstimate() обнаруживает фоновый источник освещения (если это разрешено в конфигурации);

  • getUpdatedAnchors() получает список обнаруженных точек привязки (используются для позиционирования объектов в дополненной реальности, могут быть созданы над любым обнаруженным объектом или точкой на поверхности.

Из сессии можно получить информацию об отслеживаемых объектах (включая поверхности) следующим образом:

session.getAllTrackables<Plane>(Plane::class.java)

Также сейчас поддерживается GeoSpatial API для позиционирования объектов с привязкой к географическим координатам (через session.earth), что может быть полезно для отображения подсказок при навигации, визуализации исторических изображений при посещении достопримечательностей и т.д.

Поскольку для отображения на сцене требуется работать с GL-текстурами, удобно использовать готовый рендерер, который умеет работать с фоновым изображением, отображением 3D-моделей и собственных шейдеров. Например можно взять пример отсюда, где поддерживается работа с моделями в формате WaveFront (.obj) и текстурами в PNG. Также можно использовать любую библиотеку для работы поверх EGL.

Наиболее просто начать разработку AR приложения с базового шаблона hello_ar_kotlin, в котором реализовано отображение фоновой текстуры (изображение с камеры), карты глубины, возможность загрузки 3D-моделей и примеры для добавления объектов к якорным точкам (с отслеживанием положения точки при изменении изображения на камере). В этом проекте есть дополнительные helper-классы, упрощающие управление жизненным циклом сессии ARCore и запрос необходимых разрешений (ARCoreSessionLifecycleHelper). Также Sample Renderer представляет базовый класс, который дает возможность загружать 3D-модели и текстуры, отображать растровые изображения как текстуру (класс FrameBuffer), например, изображение с камер, через BackgroundRenderer, работать с 3D-объектами и трансформировать их в набор полигонов (а также загружать из WaveFront OBJ-файла) в классе Mesh, загружать текстуры (класс Texture). В примере можно увидеть как используется поиск поверхностей и добавления якорных точек для размещения 3D-объектов:

Для добавления виртуального объекта необходимо в onSurfaceCreated загрузить mesh (3D-сетку из obj-файла) и текстуры для поверхности и отражения:

      virtualObjectAlbedoInstantPlacementTexture =
        Texture.createFromAsset(
          render,
          "models/pawn_albedo_instant_placement.png",
          Texture.WrapMode.CLAMP_TO_EDGE,
          Texture.ColorFormat.SRGB
        )

      val virtualObjectPbrTexture =
        Texture.createFromAsset(
          render,
          "models/pawn_roughness_metallic_ao.png",
          Texture.WrapMode.CLAMP_TO_EDGE,
          Texture.ColorFormat.LINEAR
        )
      virtualObjectMesh = Mesh.createFromAsset(render, "models/pawn.obj")
      virtualObjectShader =
        Shader.createFromAssets(
            render,
            "shaders/environmental_hdr.vert",
            "shaders/environmental_hdr.frag",
            mapOf("NUMBER_OF_MIPMAP_LEVELS" to cubemapFilter.numberOfMipmapLevels.toString())
          )
          .setTexture("u_AlbedoTexture", virtualObjectAlbedoTexture)
          .setTexture("u_RoughnessMetallicAmbientOcclusionTexture", virtualObjectPbrTexture)
          .setTexture("u_Cubemap", cubemapFilter.filteredCubemapTexture)
          .setTexture("u_DfgTexture", dfgTexture)

Для отображения обнаруженных поверхностей используется класс PlaneRenderer (в onDraw):

val projectionMatrix = FloatArray(16)
camera.getProjectionMatrix(projectionMatrix, 0, Z_NEAR, Z_FAR)

planeRenderer.drawPlanes(
      render,
      session.getAllTrackables<Plane>(Plane::class.java),
      camera.displayOrientedPose,
      projectionMatrix
    )

Визуализация присоединенных к якорным точкам объектов выполняется аналогично с использованием матрицы для преобразования экранных координат в мировую систему координат:

    val viewMatrix = FloatArray(16)
    val projectionMatrix = FloatArray(16)
    val modelViewMatrix = FloatArray(16) // view x model
    val modelViewProjectionMatrix = FloatArray(16) // projection x view x model

    camera.getViewMatrix(viewMatrix, 0)

    Matrix.multiplyMM(modelViewProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0)

    for ((anchor, trackable) in
      wrappedAnchors.filter { it.anchor.trackingState == TrackingState.TRACKING }) {
      // Get the current pose of an Anchor in world space. The Anchor pose is updated
      // during calls to session.update() as ARCore refines its estimate of the world.
      anchor.pose.toMatrix(modelMatrix, 0)

      // Calculate model/view/projection matrices
      Matrix.multiplyMM(modelViewMatrix, 0, viewMatrix, 0, modelMatrix, 0)
      Matrix.multiplyMM(modelViewProjectionMatrix, 0, projectionMatrix, 0, modelViewMatrix, 0)

      // Update shader properties and draw
      virtualObjectShader.setMat4("u_ModelView", modelViewMatrix)
      virtualObjectShader.setMat4("u_ModelViewProjection", modelViewProjectionMatrix)
      val texture =
        if ((trackable as? InstantPlacementPoint)?.trackingMethod ==
            InstantPlacementPoint.TrackingMethod.SCREENSPACE_WITH_APPROXIMATE_DISTANCE
        ) {
          virtualObjectAlbedoInstantPlacementTexture
        } else {
          virtualObjectAlbedoTexture
        }
      virtualObjectShader.setTexture("u_AlbedoTexture", texture)
      render.draw(virtualObjectMesh, virtualObjectShader, virtualSceneFramebuffer)
    }

Для добавления якорной точки координаты нажатия на экране преобразуются в координаты на обнаруженной поверхности:

    val hitResultList = frame.hitTest(tap)

    //находим ближайший к нам
    val firstHitResult =
      hitResultList.firstOrNull { hit ->
        when (val trackable = hit.trackable!!) {
          is Plane ->
            trackable.isPoseInPolygon(hit.hitPose) &&
              PlaneRenderer.calculateDistanceToPlane(hit.hitPose, camera.pose) > 0
          is Point -> trackable.orientationMode == Point.OrientationMode.ESTIMATED_SURFACE_NORMAL
          is InstantPlacementPoint -> true
          is DepthPoint -> true
          else -> false
        }
      }

    if (firstHitResult != null) {
      wrappedAnchors.add(WrappedAnchor(firstHitResult.createAnchor(), firstHitResult.trackable))
    }

Поскольку ARCore принимает на себя основные задачи по обнаружению и отслеживанию объектов (может распознавать двумерные объекты на данный момент, а также есть эксперименты с обнаружение 3D-объектов с использование ARCore ML), поверхностей, точек (например, угловых точек 3D-объектов для измерения расстояний), то в большинстве случае остается только решить задачу позиционирования и ориентировки 3D-объектов по указанным координатам и размещения источников света в виртуальной сцене для корректной визуализации бликов и теней на создаваемых объектах и здесь уже начинается работа с низкоуровневыми методами библиотеки EGL и преобразованиями координат, которые могут быть реализованы через дополнительные библиотеки, например libgdx.

В завершение приглашаю вас на бесплатный урок, в рамках которого рассмотрим Jetpack Compose - современный тулкит от компании Google для создания приложений под ОС Android на языке программирования Kotlin. Jetpack Compose упрощает написание и обновление визуального интерфейса приложения, предоставляя декларативный подход.

Комментарии (1)


  1. dnazarov007
    00.00.0000 00:00

    И не забываем про https://developers.google.com/ar/devices