ARKit 2 no Xamarin.iOS

O ARKit amadureceu consideravelmente desde sua introdução no ano passado no iOS 11. Em primeiro lugar, agora você pode detectar planos verticais e horizontais, o que melhora muito a praticidade das experiências internas de realidade aumentada. Além disso, há novos recursos:

  • Reconhecendo imagens e objetos de referência como a junção entre o mundo real e as imagens digitais
  • Um novo modo de iluminação que simula a iluminação do mundo real
  • A capacidade de compartilhar e persistir ambientes de AR
  • Um novo formato de arquivo preferido para armazenar conteúdo AR

Reconhecendo objetos de referência

Um recurso de demonstração no ARKit 2 é a capacidade de reconhecer imagens e objetos de referência. As imagens de referência podem ser carregadas a partir de arquivos de imagem normais (discutidos posteriormente), mas os objetos de referência devem ser verificados, usando o .ARObjectScanningConfiguration

Aplicativo de exemplo: Digitalizando e detectando objetos 3D

O exemplo é uma porta de um projeto da Apple que demonstra:

  • Gerenciamento de estado do aplicativo usando NSNotification objetos
  • Visualização personalizada
  • Gestos complexos
  • Verificação de objetos
  • Armazenando um ARReferenceObject

A varredura de um objeto de referência consome muita bateria e processador, e dispositivos mais antigos geralmente terão problemas para obter um rastreamento estável.

Gerenciamento de estado usando objetos NSNotification

Esse aplicativo usa uma máquina de estado que faz a transição entre os seguintes estados:

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

E, além disso, usa um conjunto incorporado de estados e transições quando em AppState.Scanning:

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

O aplicativo usa uma arquitetura reativa que posta NSNotificationCenter notificações de transição de estado e assina essas notificações. A configuração se parece com este trecho 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);

Um manipulador de notificação típico atualizará a interface do usuário e possivelmente modificará o estado do aplicativo, como este manipulador que é atualizado à medida que o objeto é verificado:

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 fim, Enter{State} os métodos modificam o modelo e a experiência do usuário conforme apropriado para o novo 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();
}

Visualização personalizada

O aplicativo mostra a "nuvem de pontos" de baixo nível do objeto contido em uma caixa delimitadora projetada em um plano horizontal detectado.

Esta nuvem de pontos está disponível para desenvolvedores na ARFrame.RawFeaturePoints propriedade. Visualizar a nuvem de pontos com eficiência pode ser um problema complicado. Iterar sobre os pontos e, em seguida, criar e colocar um novo nó SceneKit para cada ponto eliminaria a taxa de quadros. Como alternativa, se feito de forma assíncrona, haveria um atraso. A amostra mantém o desempenho com uma estratégia de três 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;
    }
  }
}

O resultado será semelhante a este:

point_cloud

Gestos complexos

O usuário pode dimensionar, girar e arrastar a caixa delimitadora que circunda o objeto de destino. Há duas coisas interessantes nos reconhecedores de gestos associados.

Primeiro, todos os reconhecedores de gestos são ativados somente depois que um limite é ultrapassado; Por exemplo, um dedo arrastou tantos pixels ou a rotação excede algum ângulo. A técnica é acumular o movimento até que o limite seja excedido e, em seguida, aplicá-lo de forma incremental:

// 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;
        }
    }
}

A segunda coisa interessante que está sendo feita em relação aos gestos é a maneira como a caixa delimitadora é movida em relação aos planos detectados do mundo real. Esse aspecto é discutido nesta postagem no blog do Xamarin.

Outros novos recursos do ARKit 2

Mais configurações de rastreamento

Agora, você pode usar qualquer um dos itens a seguir como base para uma experiência de realidade misturada:

AROrientationTrackingConfiguration, discutido nesta postagem de blog e no exemplo de F#, é o mais limitado e fornece uma experiência de realidade misturada ruim, pois coloca apenas objetos digitais em relação ao movimento do dispositivo, sem tentar vincular o dispositivo e a tela ao mundo real.

O ARImageTrackingConfiguration permite que você reconheça imagens 2D do mundo real (pinturas, logotipos, etc.) e use-as para ancorar imagens digitais:

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;

Há dois aspectos interessantes nessa configuração:

  • É eficiente e pode ser usado com um número potencialmente grande de imagens de referência
  • As imagens digitais são ancoradas na imagem, mesmo que essa imagem se mova no mundo real (por exemplo, se a capa de um livro for reconhecida, ela rastreará o livro à medida que ele for retirado da prateleira, deitado, etc.).

O foi discutido anteriormente e é uma configuração centrada ARObjectScanningConfiguration no desenvolvedor para digitalizar objetos 3D. É altamente intensivo em processador e bateria e não deve ser usado em aplicativos de usuário final.

A configuração final de rastreamento, ARWorldTrackingConfiguration , é o carro-chefe da maioria das experiências de realidade misturada. Essa configuração usa "odometria inercial visual" para relacionar "pontos de característica" do mundo real a imagens digitais. A geometria digital ou os sprites são ancorados em relação aos planos horizontais e verticais do mundo real ou em relação às instâncias detectadas ARReferenceObject . Nessa configuração, a origem do mundo é a posição original da câmera no espaço com o eixo Z alinhado à gravidade, e os objetos digitais "permanecem no lugar" em relação aos objetos no mundo real.

Texturização ambiental

O ARKit 2 suporta "texturização ambiental" que usa imagens capturadas para estimar a iluminação e até mesmo aplicar realces especulares a objetos brilhantes. O cubemap ambiental é construído dinamicamente e, uma vez que a câmera tenha olhado em todas as direções, pode produzir uma experiência impressionantemente realista:

Imagem de demonstração de texturização ambiental

Para usar a texturização ambiental:

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
};

Embora a textura perfeitamente reflexiva mostrada no trecho de código anterior seja divertida em um exemplo, a texturização ambiental provavelmente é melhor usada com contenção para não desencadear uma resposta de "vale estranho" (a textura é apenas uma estimativa baseada no que a câmera gravou).

Experiências de RA compartilhadas e persistentes

Outra grande adição ao ARKit 2 é a ARWorldMap classe, que permite compartilhar ou armazenar dados de rastreamento mundial. Você obtém o mapa-múndi atual com ARSession.GetCurrentWorldMapAsync ou 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 compartilhar ou restaurar o mapa-múndi:

  1. Carregue os dados do arquivo,
  2. Desarquive-o em um ARWorldMap objeto,
  3. Use isso como o valor da ARWorldTrackingConfiguration.InitialWorldMap propriedade:
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
};

O ARWorldMap único contém dados de rastreamento de mundo não visíveis e os ARAnchor objetos, não contém ativos digitais. Para compartilhar geometria ou imagens, você terá que desenvolver sua própria estratégia apropriada ao seu caso de uso (talvez armazenando/transmitindo apenas a localização e orientação da geometria e aplicando-a à estática SCNGeometry ou talvez armazenando/transmitindo objetos serializados). O benefício do é que os ARWorldMap ativos, uma vez colocados em relação a um compartilhado ARAnchor, aparecerão consistentemente entre dispositivos ou sessões.

Formato de arquivo de descrição de cena universal

O recurso principal final do ARKit 2 é a adoção pela Apple do formato de arquivo Universal Scene Description da Pixar. Esse formato substitui o formato DAE do Collada como o formato preferencial para compartilhar e armazenar ativos do ARKit. O suporte para visualização de ativos está integrado ao iOS 12 e ao Mojave. A extensão de arquivo USDZ é um arquivo zip descompactado e não criptografado contendo arquivos USD. A Pixar fornece ferramentas para trabalhar com arquivos USD, mas ainda não há muito suporte de terceiros.

Dicas de programação do ARKit

Gerenciamento manual de recursos

No ARKit, é crucial gerenciar manualmente os recursos. Isso não apenas permite altas taxas de quadros, mas também é necessário evitar um confuso "congelamento de tela". A estrutura ARKit é preguiçosa em fornecer um novo quadro de câmera (ARSession.CurrentFrame. Até que a corrente ARFrame o Dispose() chame, o ARKit não fornecerá um novo quadro! Isso faz com que o vídeo "congele", mesmo que o restante do aplicativo seja responsivo. A solução é sempre acessar ARSession.CurrentFrame com um using bloco ou chamá-lo Dispose() manualmente.

Todos os objetos derivados de são e implementam o padrão Dispose, portanto, você normalmente deve seguir esse padrão para implementar Dispose em uma classe derivada.NSObject IDisposable NSObject

Manipulando matrizes de transformação

Em qualquer aplicativo 3D, você estará lidando com matrizes de transformação 4x4 que descrevem de forma compacta como mover, girar e cisalhar um objeto através do espaço 3D. No SceneKit, esses são SCNMatrix4 objetos.

A SCNNode.Transform propriedade retorna a SCNMatrix4 matriz de transformação para o SCNNode conforme apoiado pelo tipo row-main simdfloat4x4 . Então, por exemplo:

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 você pode ver, a posição é codificada nos três primeiros elementos da linha inferior.

No Xamarin, o tipo comum para manipular matrizes de transformação é NVector4, que, por convenção, é interpretado de uma maneira principal de coluna. Ou seja, o componente de translação/posição é esperado em M14, M24, M34, não em M41, M42, M43:

linha principal vs coluna principal

Ser consistente com a escolha da interpretação da matriz é vital para o comportamento adequado. Como as matrizes de transformação 3D são 4x4, os erros de consistência não produzirão nenhum tipo de exceção em tempo de compilação ou mesmo em tempo de execução — é apenas que as operações agirão inesperadamente. Se os objetos do SceneKit / ARKit parecerem travados, voarem ou tremerem, uma matriz de transformação incorreta é uma boa possibilidade. A solução é simples: NMatrix4.Transpose realizará uma transposição de elementos no local.