ARKit 2 en Xamarin.iOS

ARKit ha madurado considerablemente desde su introducción el año pasado en iOS 11. En primer lugar, ahora puede detectar planos verticales y horizontales, lo que mejora considerablemente la práctica de las experiencias de realidad aumentada en interiores. Además, hay nuevas funcionalidades:

  • Reconocimiento de imágenes y objetos de referencia como la unión entre el mundo real y las imágenes digitales
  • Nuevo modo de iluminación que simula iluminación real
  • La capacidad de compartir y conservar entornos de AR
  • Un nuevo formato de archivo preferido para almacenar contenido de AR

Reconocimiento de objetos de referencia

Una característica principal en ARKit 2 es la capacidad de reconocer imágenes y objetos de referencia. Las imágenes de referencia se pueden cargar desde archivos de imagen normales (descritos más adelante), pero los objetos de referencia deben digitalizarse mediante la ARObjectScanningConfiguration centrada en el desarrollador.

Aplicación de ejemplo: Scanning and Detecting 3D Objects

El ejemplo es un puerto de un proyecto de Apple que muestra:

  • Administración del estado de la aplicación mediante objetos NSNotification
  • Visualización personalizada
  • Gestos complejos
  • Digitalización de objetos
  • Almacenamiento de un ARReferenceObject

La digitalización de un objeto de referencia usa la batería y el procesador de manera intensiva y, con frecuencia, los dispositivos más antiguos tendrán problemas para lograr un seguimiento estable.

Administración de estados mediante objetos NSNotification

Esta aplicación usa una máquina de estado que realiza la transición entre los estados siguientes:

  • AppState.StartARSession
  • AppState.NotReady
  • AppState.Scanning
  • AppState.Testing

Además, usa un conjunto incrustado de estados y transiciones cuando se encuentra en AppState.Scanning:

  • Scan.ScanState.Ready
  • Scan.ScanState.DefineBoundingBox
  • Scan.ScanState.Scanning
  • Scan.ScanState.AdjustingOrigin

La aplicación usa una arquitectura reactiva que envía notificaciones de transición de estado al NSNotificationCenter y se suscribe a estas notificaciones. La configuración tiene el aspecto de este fragmento de código de ViewController.cs:

// Configure notifications for application state changes
var notificationCenter = NSNotificationCenter.DefaultCenter;

notificationCenter.AddObserver(Scan.ScanningStateChangedNotificationName, State.ScanningStateChanged);
notificationCenter.AddObserver(ScannedObject.GhostBoundingBoxCreatedNotificationName, State.GhostBoundingBoxWasCreated);
notificationCenter.AddObserver(ScannedObject.GhostBoundingBoxRemovedNotificationName, State.GhostBoundingBoxWasRemoved);
notificationCenter.AddObserver(ScannedObject.BoundingBoxCreatedNotificationName, State.BoundingBoxWasCreated);
notificationCenter.AddObserver(BoundingBox.ScanPercentageChangedNotificationName, ScanPercentageChanged);
notificationCenter.AddObserver(BoundingBox.ExtentChangedNotificationName, BoundingBoxExtentChanged);
notificationCenter.AddObserver(BoundingBox.PositionChangedNotificationName, BoundingBoxPositionChanged);
notificationCenter.AddObserver(ObjectOrigin.PositionChangedNotificationName, ObjectOriginPositionChanged);
notificationCenter.AddObserver(NSProcessInfo.PowerStateDidChangeNotification, DisplayWarningIfInLowPowerMode);

Un controlador de notificaciones típico actualizará la interfaz de usuario y posiblemente modificará el estado de la aplicación, como este controlador que se actualiza a medida que se digitaliza el objeto:

private void ScanPercentageChanged(NSNotification notification)
{
    var pctNum = TryGet<NSNumber>(notification.UserInfo, BoundingBox.ScanPercentageUserKey);
    if (pctNum == null)
    {
        return;
    }
    double percentage = pctNum.DoubleValue;
    // Switch to the next state if scan is complete
    if (percentage >= 100.0)
    {
        State.SwitchToNextState();
    }
    else
    {
        DispatchQueue.MainQueue.DispatchAsync(() => navigationBarController.SetNavigationBarTitle($"Scan ({percentage})"));
    }
}

Por último, los métodos Enter{State} modifican el modelo y la experiencia de usuario según corresponda al nuevo estado:

internal void EnterStateTesting()
{
    navigationBarController.SetNavigationBarTitle("Testing");
    navigationBarController.ShowBackButton(false);
    loadModelButton.Hidden = true;
    flashlightButton.Hidden = false;
    nextButton.Enabled = true;
    nextButton.SetTitle("Share", UIControlState.Normal);

    testRun = new TestRun(sessionInfo, sceneView);
    TestObjectDetection();
    CancelMaxScanTimeTimer();
}

Visualización personalizada

La aplicación muestra la "nube de puntos" de bajo nivel del objeto contenido en un rectángulo de selección proyectado en un plano horizontal detectado.

Esta nube de puntos está disponible para los desarrolladores en la propiedad ARFrame.RawFeaturePoints. Visualizar la nube de puntos de forma eficaz puede ser un problema complicado. Recorrer en iteración los puntos y, después, crear y colocar un nuevo nodo SceneKit para cada punto degradaría la velocidad de fotogramas. Como alternativa, si se hace de forma asincrónica, habría un retraso. En el ejemplo, se mantiene el rendimiento con una estrategia de tres partes:

internal static SCNGeometry CreateVisualization(NVector3[] points, UIColor color, float size)
{
  if (points.Length == 0)
  {
    return null;
  }

  unsafe
  {
    var stride = sizeof(float) * 3;

    // Pin the data down so that it doesn't move
    fixed (NVector3* pPoints = &amp;points[0])
    {
      // Important: Don't unpin until after `SCNGeometry.Create`, because geometry creation is lazy

      // Grab a pointer to the data and treat it as a byte buffer of the appropriate length
      var intPtr = new IntPtr(pPoints);
      var pointData = NSData.FromBytes(intPtr, (System.nuint) (stride * points.Length));

      // Create a geometry source (factory) configured properly for the data (3 vertices)
      var source = SCNGeometrySource.FromData(
        pointData,
        SCNGeometrySourceSemantics.Vertex,
        points.Length,
        true,
        3,
        sizeof(float),
        0,
        stride
      );

      // Create geometry element
      // The null and bytesPerElement = 0 look odd, but this is just a template object
      var template = SCNGeometryElement.FromData(null, SCNGeometryPrimitiveType.Point, points.Length, 0);
      template.PointSize = 0.001F;
      template.MinimumPointScreenSpaceRadius = size;
      template.MaximumPointScreenSpaceRadius = size;

      // Stitch the data (source) together with the template to create the new object
      var pointsGeometry = SCNGeometry.Create(new[] { source }, new[] { template });
      pointsGeometry.Materials = new[] { Utilities.Material(color) };
      return pointsGeometry;
    }
  }
}

El resultado tiene el aspecto siguiente:

point_cloud

Gestos complejos

El usuario puede escalar, girar y arrastrar el rectángulo de selección que rodea el objeto de destino. Existen dos cosas interesantes en los reconocedores de gestos asociados.

En primer lugar, todos los reconocedores de gestos se activan solo después de que se haya superado un umbral; por ejemplo, un dedo ha arrastrado tantos píxeles o la rotación supera cierto ángulo. La técnica consiste en acumular el movimiento hasta que se haya superado el umbral y, a continuación, aplicarlo incrementalmente:

// A custom rotation gesture recognizer that fires only when a threshold is passed
internal partial class ThresholdRotationGestureRecognizer : UIRotationGestureRecognizer
{
    // The threshold after which this gesture is detected.
    const double threshold = Math.PI / 15; // (12°)

    // Indicates whether the currently active gesture has exceeded the threshold
    private bool thresholdExceeded = false;

    private double previousRotation = 0;
    internal double RotationDelta { get; private set; }

    internal ThresholdRotationGestureRecognizer(IntPtr handle) : base(handle)
    {
    }

    // Observe when the gesture's state changes to reset the threshold
    public override UIGestureRecognizerState State
    {
        get => base.State;
        set
        {
            base.State = value;

            switch(value)
            {
                case UIGestureRecognizerState.Began :
                case UIGestureRecognizerState.Changed :
                    break;
                default :
                    // Reset threshold check
                    thresholdExceeded = false;
                    previousRotation = 0;
                    RotationDelta = 0;
                    break;
            }
        }
    }

    public override void TouchesMoved(NSSet touches, UIEvent evt)
    {
        base.TouchesMoved(touches, evt);

        if (thresholdExceeded)
        {
            RotationDelta = Rotation - previousRotation;
            previousRotation = Rotation;
        }

        if (! thresholdExceeded && Math.Abs(Rotation) > threshold)
        {
            thresholdExceeded = true;
            previousRotation = Rotation;
        }
    }
}

La segunda cosa interesante que se hace respecto de los gestos es la forma en que se mueve el rectángulo de selección en relación con los planos reales detectados. Este aspecto se describe en esta entrada de blog de Xamarin.

Otras características nuevas de ARKit 2

Más configuraciones de seguimiento

Ahora puede usar cualquiera de las siguientes opciones como base para una experiencia de realidad mixta:

AROrientationTrackingConfiguration, descrito en esta entrada de blog y el ejemplo de F#, es el más limitado y proporciona una mala experiencia de realidad mixta, ya que solo coloca objetos digitales en relación con el movimiento del dispositivo, sin intentar vincular el dispositivo y la pantalla al mundo real.

ARImageTrackingConfiguration permite reconocer imágenes 2D reales (pinturas, logotipos, etc.) y usarlas para vincularlas a imágenes digitales:

var imagesAndWidths = new[] {
    ("cover1.jpg", 0.185F),
    ("cover2.jpg", 0.185F),
     //...etc...
    ("cover100.jpg", 0.185F),
};

var referenceImages = new NSSet<ARReferenceImage>(
    imagesAndWidths.Select( imageAndWidth =>
    {
      // Tuples cannot be destructured in lambda arguments
        var (image, width) = imageAndWidth;
        // Read the image
        var img = UIImage.FromFile(image).CGImage;
        return new ARReferenceImage(img, ImageIO.CGImagePropertyOrientation.Up, width);
    }).ToArray());

configuration.TrackingImages = referenceImages;

Existen dos aspectos interesantes respecto de esta configuración:

  • Es eficaz y se puede usar con un número potencialmente elevado de imágenes de referencia.
  • Las imágenes digitales se anclan a la imagen, incluso si esa imagen se mueve en el mundo real (por ejemplo, si se reconoce la portada de un libro, realizará un seguimiento del libro a medida que se extraiga de la estantería, la colocación, etc.).

Se ha analizado ARObjectScanningConfiguration anteriormente y es una configuración centrada en el desarrollador para la digitalización de objetos 3D. Usa bastante el procesador y la batería, y no debe usarse en aplicaciones de usuario final.

La configuración de seguimiento final, ARWorldTrackingConfiguration, es el caballo de tiro de la mayoría de las experiencias de realidad mixta. Esta configuración usa "odometría inercial visual" para relacionar "puntos de características" reales con imágenes digitales. La geometría digital o los sprites están anclados en relación con los planos horizontales y verticales del mundo real o con respecto a las instancias ARReferenceObject detectadas. En esta configuración, el origen del mundo es la posición original de la cámara en el espacio con el eje Z alineado con la gravedad, y los objetos digitales "permanecen en su lugar" en relación con los objetos del mundo real.

Texturización del entorno

ARKit 2 admite la "texturización del entorno", que usa imágenes capturadas para calcular la iluminación e incluso aplicar resaltados especulares a objetos brillantes. El mapa de cubos ambiental se crea de manera dinámica y, una vez que la cámara ha buscado en todas las direcciones, puede producir una experiencia impresionantemente realista:

imagen de demostración de texturización del entorno

Para usar la texturización del entorno:

var sphere = SCNSphere.Create(0.33F);
sphere.FirstMaterial.LightingModelName = SCNLightingModel.PhysicallyBased;
// Shiny metallic sphere
sphere.FirstMaterial.Metalness.Contents = new NSNumber(1.0F);
sphere.FirstMaterial.Roughness.Contents = new NSNumber(0.0F);

// Session configuration:
var configuration = new ARWorldTrackingConfiguration
{
    PlaneDetection = ARPlaneDetection.Horizontal | ARPlaneDetection.Vertical,
    LightEstimationEnabled = true,
    EnvironmentTexturing = AREnvironmentTexturing.Automatic
};

Aunque la textura perfectamente reflectante mostrada en el fragmento de código precedente es divertida en una muestra, es probable que la texturización del entorno se utilice con moderación para no provocar una respuesta de "valle inquietante" (la textura es solo una estimación basada en lo que la cámara grabó).

Experiencias de AR compartidas y persistentes

Otra adición importante a ARKit 2 es la clase ARWorldMap, que permite compartir o almacenar datos de seguimiento del mundo. Obtiene el mapa del mundo actual con ARSession.GetCurrentWorldMapAsync o GetCurrentWorldMap(Action<ARWorldMap,NSError>):

// Local storage
var PersistentWorldPath => Environment.GetFolderPath(Environment.SpecialFolder.Personal) + "/arworldmap";

// Later, after scanning the environment thoroughly...
var worldMap = await Session.GetCurrentWorldMapAsync();
if (worldMap != null)
{
    var data = NSKeyedArchiver.ArchivedDataWithRootObject(worldMap, true, out var err);
    if (err != null)
    {
        Console.WriteLine(err);
    }
    File.WriteAllBytes(PersistentWorldPath, data.ToArray());
}

Para compartir o restaurar el mapa del mundo:

  1. Cargue los datos del archivo,
  2. Desarchívelo en un objeto ARWorldMap,
  3. Úselo como valor para la propiedad ARWorldTrackingConfiguration.InitialWorldMap:
var data = NSData.FromArray(File.ReadAllBytes(PersistentWorldController.PersistenWorldPath));
var worldMap = (ARWorldMap)NSKeyedUnarchiver.GetUnarchivedObject(typeof(ARWorldMap), data, out var err);

var configuration = new ARWorldTrackingConfiguration
{
    PlaneDetection = ARPlaneDetection.Horizontal | ARPlaneDetection.Vertical,
    LightEstimationEnabled = true,
    EnvironmentTexturing = AREnvironmentTexturing.Automatic,
    InitialWorldMap = worldMap
};

El ARWorldMap solo contiene datos de seguimiento del mundo no visibles y los objetos ARAnchor. No contiene recursos digitales. Para compartir geometría o imágenes, tendrá que desarrollar su propia estrategia adecuada para su caso de uso (quizá almacenando o transmitiendo solo la ubicación y orientación de la geometría y aplicándola a objetos estáticos SCNGeometry o quizá almacenando o transmitiendo objetos serializados). La ventaja de ARWorldMap es que los recursos, una vez colocados en relación con un ARAnchor compartido, aparecerán de forma coherente entre dispositivos o sesiones.

Formato de archivo de descripción de escena universal

La última característica de titular de ARKit 2 es la adopción del formato de archivo de descripción de escena universal de Pixar por parte de Apple. Este formato reemplaza el formato DAE de Collada como el formato preferido para compartir y almacenar recursos ARKit. La compatibilidad con la visualización de recursos está integrada en iOS 12 y Mojave. La extensión de archivo USDZ es un archivo ZIP sin comprimir y sin cifrar que contiene archivos USD. Pixar proporciona herramientas para trabajar con archivos USD, pero aún no hay compatibilidad con terceros.

Sugerencias de programación de ARKit

Administración manual de recursos

En ARKit, es fundamental administrar manualmente los recursos. Esto no solo permite altas velocidades de fotogramas; en realidad, es necesario evitar una "inmovilización de pantalla" confusa. El marco ARKit es "holgazán" a la hora de proporcionar un nuevo fotograma de cámara(ARSession.CurrentFrame. Hasta que el ARFrame actual haya llamado a Dispose() en él, ARKit no proporcionará un nuevo fotograma. Esto hace que el vídeo se "inmovilice" aunque el resto de la aplicación tenga capacidad de respuesta. La solución consiste en acceder a ARSession.CurrentFrame siempre con un bloque de using o llamar manualmente a Dispose() en él.

Todos los objetos derivados de NSObject son IDisposable y NSObject implementa el patrón de eliminación, por lo que, por lo general, debe seguir este patrón para implementar Dispose en una clase derivada.

Manipulación de matrices de transformación

En cualquier aplicación 3D, va a tratar con matrices de transformación 4x4 que describen de forma compacta cómo mover, girar y escalar un objeto a través del espacio 3D. En SceneKit, estas son los objetos SCNMatrix4.

La SCNNode.Transform propiedad devuelve la matriz de SCNMatrix4 transformación de como SCNNode está respaldada por el tipo principal simdfloat4x4 de fila. Así, por ejemplo:

var node = new SCNNode { Position = new SCNVector3(2, 3, 4) };  
var xform = node.Transform;
Console.WriteLine(xform);
// Output is: "(1, 0, 0, 0)\n(0, 1, 0, 0)\n(0, 0, 1, 0)\n(2, 3, 4, 1)"

Como puede ver, la posición se codifica en los tres primeros elementos de la fila inferior.

En Xamarin, el tipo común para manipular matrices de transformación es NVector4, que por convención se interpreta de forma column-major. Es decir, se espera que el componente de traducción/posición esté en M14, M24, M34, no en M41, M42, M43:

fila principal frente a columna principal

Ser coherente con la elección de la interpretación de matriz es fundamental para el comportamiento adecuado. Dado que las matrices de transformación 3D son 4x4, los errores de coherencia no producirán ningún tipo de excepción en tiempo de compilación o incluso en tiempo de ejecución. Lo que pasará será que las operaciones actuarán inesperadamente. Si los objetos SceneKit o ARKit parecen estar atascados, volar o temblar, es probable que la matriz de transformación sea incorrecta. La solución es sencilla: NMatrix4.Transpose realizará una transposición local de elementos.