Realidad aumentada con ARCore de Google

,

La realidad aumentada es un recurso técnico que brinda a los usuarios una experiencia interactiva a partir de la combinación de dimensiones virtuales y físicas mediante el uso de dispositivos digitales.

Uno de los casos más sonados de realidad aumentada, que salió en verano en 2016, es la aplicación de Pokemon GO, donde se ofrece la opción de capturar a estas criaturas a través de la cámara de nuestro móvil en el entorno que nos rodea:

Pero no solo sirve para ocio, como muchos podrían pensar; la realidad aumentada se puede aplicar en otros sectores como el comercio. Un ejemplo es IKEA, su aplicación Ikea Place nos permite ver cómo quedarían nuestras casas con los productos de su catálogo a través de esta tecnología:

Lo cierto es que los usos que podemos hacer de la realidad aumentada son infinitos: moda, decoración, seguridad e investigación policial, publicidad, ciencia, enseñanza, ocio, etc.

¿Qué necesitamos conocer antes de implementar una solución de RA en Android?

ARCore

En este post vamos a hablar de la realidad aumentada en Android a través del uso de ARCore, una librería presentada en 2017 en respuesta al lanzamiento de ARKit de Apple. ARCore permite a nuestros dispositivos, a través de diferentes APIs, entender el entorno que nos rodea a través de la cámara.

¿Qué hace? Analiza el lugar en el que estamos para crear un mapa virtual o escena sobre la que poder colocar diferentes elementos virtuales.

Dispositivos compatibles con ARCore

Por desgracia, esta librería tiene compatibilidad limitada. Existe una lista de dispositivos soportados, ya que para poder utilizarla se requiere de los Servicios de Google Play para RA (Realidad aumentada).

Para saber qué dispositivos están soportados para ejecutar ARCore, consulta la documentación de Google que se va actualizando cuando se añaden nuevos dispositivos. Tan solo usa Ctrl + F y busca el tuyo para ver si es compatible.

El pasado diciembre, Google volvió a actualizar su lista de dispositivos añadiendo 27 nuevos modelos, que se suman a los 30 dispositivos incluidos hace justo un año, completando una larga lista de dispositivos compatibles con ARCore. Si tu dispositivo es relativamente nuevo, quizás aún no han desarrollado la compatibilidad con la librería, pero lo harán.

Alternativas a ARCore

Existen otras alternativas a ARCore: algunos ejemplos son ARKit para iOS y Vuforia para Android.

En el caso de Vuforia existe un inconveniente: aunque puedes usarla sin ningún coste, si quieres publicar la aplicación en la store, tienes que pagar una licencia. De lo contrario, estarías infringiendo su política de uso y podrían denunciarte por ello.

Sceneform

Sceneform es una librería de Android Studio fuertemente ligada a ARCore que importa modelos 3D para convertirlos en formatos compatibles y los renderiza en el momento de la compilación. Esta biblioteca ayuda a importar modelos, inspeccionar planos, administrar la experiencia del usuario y crear escenas de realidad aumentada.

Sceneform permite acceder a ARCore con menor dificultad, ya que en principio no requiere conocimientos previos de OpenGL. En otras palabras, es un middleware que integra funciones de ARCore y OpenGL.

Gracias a Sceneform se pueden crear aplicaciones de realidad aumentada más fácilmente, ya que reduce la curva de aprendizaje al no requerir un conocimiento profundo de OpenGL.

Algo de caos en sus versiones

Antes de entrar más en profundidad, es preciso aclarar cómo se han ido sucediendo las versiones publicadas por Google de Sceneform:

  • De la 1.0.0 a la 1.15.0 todas las versiones eran de código cerrado. Estas se incluían como una dependencia externa de gradle.
  • La versión 1.16.0 pasó a ser de código abierto. Se incluía en el proyecto en bruto como un módulo más de este.
  • La versión 1.17.0 no es recomendable utilizarla al presentar errores que no permiten renderizar los modelos en 3D.
  • La versión 1.17.1 es la versión más actual de la librería y es una versión idéntica a la 1.15.0, por lo que da igual usar una u otra.

¿Está deprecado Sceneform?

Aunque la versión que vamos a utilizar en este post es la desarrollada por Google, este proyecto quedó archivado en 2020 y no parece tener continuidad oficial. Sin embargo, una comunidad de desarrolladores encabezados por Thomas Gorisse ha continuado el desarrollo de esta librería que continua recibiendo actualizaciones periódicas, siendo la última de mayo de 2022, aquí puedes encontrar su enlace a GitHub.

Formatos soportados por Sceneform

Llegados a este punto, deberíamos darnos cuenta que ARCore no es más que la librería que se encarga de leer el entorno, crear el mapa virtual y colocar los elementos 3D en el plano. No podría funcionar sin algo que le brinde esos modelos 3D renderizados en los formatos soportados. Aquí es donde entra en juego Sceneform. A lo largo de sus versiones, los formatos soportados han ido variando:

Hasta la versión 1.15.0 y la 1.17.1 los formatos soportados eran SFA y SFB y debían ser previamente convertidos de modelos FBX y OBJ. Esto no era posible realizarlo en tiempo de ejecución y debía hacerse a través de un plugin instalado en Android Studio.

Es en la versión 1.16.0 donde se añade la compatibilidad con el formato que vamos a utilizar en este post: glTF.

Formato glTF

El problema que tenían los anteriores formatos es que debíamos tener por un lado el modelo 3D sin texturas y por otro lado las texturas; en cuanto a código, era más pesado de programar y ejecutar.

El formato glTF fue diseñado para un tamaño de archivo compacto, carga rápida, independencia en tiempo de ejecución y representación completa de escenas en 3D. A menudo se describe como “el JPEG de 3D” y puede usar extensiones de archivo .gltf (JSON / ASCII) o .glb (formato de archivo binario).

Ejemplo de un caso práctico real

A continuación vamos a integrar el ecosistema de ARCore junto a Sceneform en una aplicación real, viendo un ejemplo de cómo se utiliza y qué necesitamos para hacerlo funcionar. Para integrar ARCore en nuestra aplicación necesitamos otra herramienta que renderice nuestros modelos 3D. Aquí entra en juego Sceneform, la opción elegida.

Obteniendo el modelo 3D

En primer lugar, debes saber que para la utilización de ARCore necesitas, como mínimo, la API 24. Comenzamos creando un proyecto en Android Studio, en lenguaje Kotlin, que tenga esta versión mínima de API.

Ahora vamos a obtener el modelo 3D que queremos utilizar. Como hemos comentado, debe estar en formato glTF o su binario glb, que también soporta movimiento y animación.

Hasta el 30 de junio de 2021 teníamos disponible la web de Poly de Google, una biblioteca con muchos modelos 3D que podíamos utilizar. Actualmente está cerrada, pero podemos descargar algunos modelos 3D o crearlos nosotros mismos si tenemos los conocimientos para ello.

Una vez descargado el modelo 3D que queremos, lo vamos a añadir a nuestro proyecto. Para ello creareos la carpeta raw en nuestro árbol de directorios de los recursos:

Cuando ya tenemos la carpeta creada, arrastramos los modelos que hayamos elegido y los metemos dentro:

Añadiendo Sceneform y ARCore

Primero ARCore

En primer lugar, vamos a añadir a nuestro proyecto la dependencia de ARCore en el archivo build.gradle a nivel de app:

implementation 'com.google.ar:core:1.26.0'

Para comprobar la última versión que hay disponible, podemos consultarlo en la documentación de Google.

Ahora Sceneform

Vamos a usar la versión 1.16.0 de Sceneform. Además de tener que añadirlo al proyecto como si fuera un módulo más de este, debemos corregir manualmente algunos errores, pero nos servirá también para ver un poco qué carpetas y archivos componen la librería. Una vez tenemos nuestro proyecto ya creado, vamos a descargar la librería de GitHub:

https://github.com/google-ar/sceneform-android-sdk/releases/tag/v1.16.0

Lo extraemos y tenemos como resultado varias carpetas y los archivos de Markdown, entre otros. Las que nos interesan son sceneformsrc y sceneformux

Las movemos a la raíz de nuestro proyecto. Para que sea más sencillo y no haya problemas, abrimos el proyecto en nuestro explorador del sistema y las copiamos ahí directamente.

project
+-- app
|   +-- build.gradle
|   +-- ...
+-- sceneformsrc
+-- sceneformux
+-- build.gradle
+-- settings.gradle
+-- ...

El siguiente paso es configurar estas carpetas como módulos de nuestro proyecto, para ello vamos al fichero settings.gradle y añadimos lo siguiente:

include ':sceneform'

project(':sceneform').projectDir = new File('sceneformsrc/sceneform')

include ':sceneformux'

project(':sceneformux').projectDir = new File('sceneformux/ux')

Y a continuación, en nuestro fichero build.gradle a nivel de aplicación, en el apartado de dependencias, añadimos:

api project(":sceneformux")

Ahora le damos a Sync Now o, es esta opción no aparece, al icono de sincronizar con gradle (el del elefante con la flecha azul), para actualizar el proyecto. El resultado es algo como esto:

El siguiente paso, que quizás sea el más engorroso, es migrar a AndroidX estos nuevos módulos: hay que ir abriendo una a una las clases y actualizar los import que estarán marcados en rojo.

Podemos migrarlo a través de la opción de Android Studio, aunque es recomendable hacerlo manualmente, ya que no se trata de demasiados archivos y así vas viendo los cambio. Es una elección personal.

Para que sea más sencillo, a continuación aparece cómo están la primera vez y cómo hay que cambiarlos:

import android.support.annotation.Nullable -> import androidx.annotation.Nullable
import android.support.annotation.NotNull -> import androidx.annotation.NotNull
import android.support.annotation.ColorInt -> import androidx.annotation.ColorInt
import android.support.annotation.RequiresApi -> import androidx.annotation.RequiresApi
import android.support.annotation.IntRange -> import androidx.annotation.IntRange
import android.support.annotation.VisibleForTesting -> import androidx.annotation.VisibleForTesting
import android.support.annotation.GuardedBy -> import androidx.annotation.GuardedBy
import android.support.annotation.CallSuper -> import androidx.annotation.CallSuper
import android.support.annotation.MainThread -> import androidx.annotation.MainThread

import android.support.v4.app.ActivityCompat -> import androidx.core.app.ActivityCompat
import android.support.v4.app.Fragment -> import androidx.fragment.app.Fragment
import android.support.v4.app.FragmentActivity -> import androidx.fragment.app.FragmentActivity;
import android.support.v4.content.ContextCompat -> import androidx.core.content.ContextCompat;

import android.support.v7.widget.AppCompatImageView -> import androidx.appcompat.widget.AppCompatImageView;

Cuando los tenemos todos, probamos a que nuestra aplicación compile antes de seguir con el resto del proceso.

Añadiendo los permisos necesarios

El siguiente paso es añadir permisos y características en nuestro fichero AndroidManifest.xml del módulo app. Primero, fuera de application añadimos lo siguiente:




 



 


Ahora dentro de application y justo antes de la definición de nuestros activities, añadimos:



Nuestro fichero AndroidManifest.xml debería quedar igual a esto:

        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.ARCoreAlten">

        

        
            
                

                
            
        
    

Comenzamos a construir nuestra implementación

Una vez tenemos el terreno preparado, podemos comenzar a implementar nuestra aplicación. Lo primero es añadir un FragmentContainerView en el layout del MainActivity.kt:




    

La clase ArFragment tiene características que nos facilitan el desarrollo: no necesitamos programar la solicitud de los permisos de cámara, ya que se encarga automáticamente de hacerlo, y también comprueba que tenemos los servicios de RA instalados y actualizados en nuestro dispositivo.

Con nuestro diseño ya listo, vamos a la clase MainActivity.kt y creamos una instancia de ArFragment que será necesaria más adelante:

private var arFragment: ArFragment? = null

Para no tener problemas de incompatibilidad, creamos un método que devuelva un valor booleano para comprobar la versión de OpenGL soportada por el dispositivo en el que se está ejecutando la aplicación:

private fun checkSystemSupport(): Boolean {
    val openGlVersion: String = (getSystemService(Context.ACTIVITY_SERVICE)
            as ActivityManager).deviceConfigurationInfo.glEsVersion
    return if (openGlVersion.toDouble() >= MIN_OPENGL_VERSION) {
        true

    } else {
        Toast.makeText(
            this,
            getString(R.string.open_gl_version_not_supported),
            Toast.LENGTH_SHORT
        ).show()
        finish()
        false
    }
}

Si el dispositivo es compatible, podemos continuar con la inicialización del fragment que hemos creado antes. Para tener el código más organizado, dentro de la comprobación del método anterior, creamos otro método al que llamaremos setOnTapInPlane

En este método iniciamos nuestro fragment a través del supportFragmentManager:

arFragment = supportFragmentManager.findFragmentById(R.id.activity_main__container__camera_area) as ArFragment

Con el fragment ya inicializado, creamos el listener que se encarga de llamar al modelo 3D al tocar la pantalla:

arFragment?.setOnTapArPlaneListener

En setOnTapArPlaneListener, se crea un objeto Anchor. Los objetos anchor ayudan a traer objetos virtuales a la pantalla y los mantienen en la misma posición y orientación en el espacio.

Dentro de este método vamos a crear un contador para que no nos permita crear más de un modelo en pantalla, ya que de lo contrario, podríamos crear infinitos y sobrecargar la memoria RAM del dispositivo.

Añadir limite de modelos en pantalla

Vamos a crear una variable de tipo entero que actuará de contador, la iniciamos a 0:

private var tapNumber = 0

Dentro del onTapArPlaneListener incrementamos en 1 su valor y comprobamos si el número de veces que hemos tocado la pantalla es igual al contador que hemos creado, de esta forma frenamos la proliferación de modelos infinitos:

arFragment?.setOnTapArPlaneListener { hitResult, _, _ ->
            tapNumber++
            if (tapNumber == MAX_TAP_NUMBER) {
                
            }
        }

Construyendo nuestro modelo 3D

Ahora tenemos que crear una clase de tipo ModelRenderable. Esta clase se utiliza para renderizar el modelo 3D descargado o creado adjuntándolo a un AnchorNode:

private fun createModelRenderable(hitResult: HitResult) {
        val anchor: Anchor = hitResult.createAnchor()
        ModelRenderable.builder()
        
            /*
             * La función setSource() ayuda a obtener la fuente del 
             * modelo 3D.
             */ 
        
            .setSource(this, R.raw.astronaut)
            
            /*
             * La función setIsFilamentGltf() comprueba si es un 
             * archivo glb.
            */ 
            
            .setIsFilamentGltf(true)
            .build()
            
            /*
             * La función thenAccept() es llamada para recibir el
             * modelo adjuntando un AnchorNode con el 
             * ModelRenderable. 
             */
            
            .thenAccept { modelRenderable: ModelRenderable? ->
                
            }.exceptionally { throwable: Throwable ->
                val builder: AlertDialog.Builder = AlertDialog.Builder(this)
                builder.setMessage(
                    getString(R.string.something_is_not_right)
                            + throwable.message
                ).show()
                null
            }
    }

Añadiendo y cargando el modelo 3D

Dentro de la función thenAccept() vamos a crear otra nueva llamada addModel que va a recibir como parámetros el anchor y el modelRenderable creados anteriormente.

private fun addModel(anchor: Anchor, modelRenderable: ModelRenderable?) {
    
    /*
     * Dentro se crea un objeto AnchorNode. Es el nodo raíz de la escena. 
     * AnchorNode se posiciona automáticamente en el mundo, según el Anchor.
     */
    
        val anchorNode = AnchorNode(anchor)
        anchorNode.setParent(arFragment?.arSceneView?.scene)

    /*
     * TransformableNode ayuda al usuario a interactuar con el modelo 3D, 
     * como cambiar de posición, redimensionar, rotar, etc.
     */

        val transformableNode = TransformableNode(arFragment?.transformationSystem)
        transformableNode.setParent(anchorNode)

        transformableNode.renderable = modelRenderable
        transformableNode.select()
    }

Una vez tenemos todos los métodos creados, la clase MainActivity.kt debe quedar así:

package com.isaacdelosreyes.arcorealten

import android.app.ActivityManager
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.ar.core.Anchor
import com.google.ar.core.HitResult
import com.google.ar.sceneform.AnchorNode
import com.google.ar.sceneform.rendering.ModelRenderable
import com.google.ar.sceneform.ux.ArFragment
import com.google.ar.sceneform.ux.TransformableNode

const val MIN_OPENGL_VERSION = 3.0
const val MAX_TAP_NUMBER = 1

class MainActivity : AppCompatActivity() {

    private var arFragment: ArFragment? = null
    private var tapNumber = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (checkSystemSupport()) {
            setOnTapInPlane()
        }
    }

    private fun setOnTapInPlane() {
        arFragment = supportFragmentManager.findFragmentById(
            R.id.activity_main__container__camera_area
        ) as ArFragment
        arFragment?.setOnTapArPlaneListener { hitResult, _, _ ->
            tapNumber++
            if (tapNumber == MAX_TAP_NUMBER) {
                createModelRenderable(hitResult)
            }
        }
    }

    private fun createModelRenderable(hitResult: HitResult) {
        val anchor: Anchor = hitResult.createAnchor()
        ModelRenderable.builder()
            .setSource(this, R.raw.astronaut)
            .setIsFilamentGltf(true)
            .build()
            .thenAccept { modelRenderable: ModelRenderable? ->
                addModel(
                    anchor,
                    modelRenderable
                )
            }.exceptionally { throwable: Throwable ->
                val builder: AlertDialog.Builder = AlertDialog.Builder(this)
                builder.setMessage(
                    getString(R.string.something_is_not_right)
                            + throwable.message
                ).show()
                null
            }
    }

    private fun checkSystemSupport(): Boolean {
        val openGlVersion: String = (getSystemService(Context.ACTIVITY_SERVICE)
                as ActivityManager).deviceConfigurationInfo.glEsVersion
        return if (openGlVersion.toDouble() >= MIN_OPENGL_VERSION) {
            true

        } else {
            Toast.makeText(
                this,
                getString(R.string.open_gl_version_not_supported),
                Toast.LENGTH_SHORT
            ).show()
            finish()
            false
        }
    }

    private fun addModel(anchor: Anchor, modelRenderable: ModelRenderable?) {
        val anchorNode = AnchorNode(anchor)
        anchorNode.setParent(arFragment?.arSceneView?.scene)

        val transformableNode = TransformableNode(arFragment?.transformationSystem)
        transformableNode.setParent(anchorNode)

        transformableNode.renderable = modelRenderable
        transformableNode.select()
    }
}

Probando la aplicación

Ejecutamos la aplicación y nos pedirá los permisos de cámara, los aceptamos y apuntamos con el objetivo a una superficie plana hasta que dejemos de ver la animación de la mano sosteniendo el móvil y aparezcan unos puntos en la superficie.

Pulsamos dentro de la zona marcada con puntos y, si todo ha ido bien, se cargará nuestro modelo 3D. A veces tarda en detectar la superficie.

Conclusiones

Nos encontramos ante una herramienta muy potente con la que podemos conseguir grandes resultados sin la necesidad de tener grandes conocimientos en diseño y renderizado 3D. El tutorial de este post es lo más básico que podemos hacer. De ahí a grandes ejemplos como la sección de Realidad Aumentada de Google Maps o el anteriormente citado Pokemon Go, que también utiliza ARCore.

El mayor inconveniente que puedes encontrar, es que el resultado depende mucho de la calidad de la cámara del dispositivo en el que pruebes y de las condiciones lumínicas del lugar que te encuentres: cuanto peor sean, más tardará en detectar la superficie. Pero como decía antes, esto es una implementación muy básica; con más desarrollo se podrá optimizar el rendimiento para brindar una mayor usabilidad al usuario final.

Referencias

Isaac de los Reyes,
Android Developer