5 minute read

Download the code

Previously I discussed how to use .NET MAUI to create a basic augmented reality app on iOS. This involved creating a custom control that’s backed by a handler that uses ARKit and SceneKit to overlay a 3D cube on the camera output at the world origin.

In this blog post I’ll build on this to overlay an image on the camera output, and will enable the image to respond to touch interaction.

Note: You’ll need a physical iPhone to run an augmented reality app. ARKit requires the use of the camera, and you won’t have much joy using an iOS simulator.

Overlay an image on the scene

Objects that you overlay on the camera output are called nodes. By default, nodes don’t have a shape. Instead, you give them a geometry and apply materials to the geometry to provide a visual appearance. Nodes are represented by the SceneKit SCNode type.

One of the geometries provided by SceneKit is SCNPlane, which represents a square or rectangle. This type essentially acts as a surface on which to place other objects.

The following example shows the ImageNode type, which derives from SCNNode, that can be used to overlay an image onto a scene:

using SceneKit;
using UIKit;

namespace ARKitDemo.Platforms.iOS;

public class ImageNode : SCNNode
{
    public ImageNode(UIImage? image, float width, float height)
    {
        var rootNode = new SCNNode
        {
            Geometry = CreateGeometry(image, width, height),
            Constraints = new[] { new SCNBillboardConstraint() } // Make the node always face the camera
        };
        AddChildNode(rootNode);
    }

    static SCNGeometry CreateGeometry(UIImage? image, float width, float height)
    {
        var material = new SCNMaterial();
        material.Diffuse.Contents = image;
        material.DoubleSided = true;

        var geometry = SCNPlane.Create(width, height);
        geometry.Materials = new[] { material };

        return geometry;
    }
}

The ImageNode constructor takes a UIImage argument that represents the image to be added to the scene, and float arguments that represent the width and height of the image in the scene. The constructor creates a SCNNode, assigns a geometry to its Geometry property, and adds the node as a child node to the SCNNode.

The CreateGeometry method creates a SCNMaterial object that represents the image. Then, a SCNPlane object is created, of size width x height, and the SCNMaterial object is assigned to the geometry. Therefore, the shape of the node is defined by the SCNPlane object of width x height, and the material (the image) defines the visual appearance of the node.

As my previous blog post explained, the MauiARView type encapsulates the ARSCNView and ARSession objects that provide augmented reality functionality on iOS. Its AddContent method, which is called from the StartARSession method, is used to place content into the AR scene:

using ARKit;
using UIKit;

namespace ARKitDemo.Platforms.iOS;

public class MauiARView : UIView
{
    ARSCNView? _arView;
    ...

    void AddContent()
    {
        float width = 0.1f;
        float height = 0.1f;

        UIImage? image = UIImage.FromFile("dotnet_bot.png");
        var imageNode = new ImageNode(image, width, height);
        _arView?.Scene.RootNode.AddChildNode(imageNode);
    }
    ...
}

In this example, the AddContent method defines a width and height for the image, and loads a specified image into a UIImage from the app bundle. The width and height of the image in the scene are defined to be 10cm (1f=1m, 0.1f=10cm, 0.01f=1cm). An ImageNode is then created from the image, and added to the scene at the world origin (0,0,0):

Because the image contains a transparent background it blends well into the scene.

Overlaying a node, or multiple nodes, onto a scene is typically the first step in creating an augmented reality app. However, such apps typically require interaction with the nodes.

Interact with a node in the scene

Augmented reality apps often allow touch-based interaction with the nodes that are overlayed on a scene. The UIGestureRecognizer types can be used to detect gestures on nodes, which can then be manipulated as required.

The MauiARView type must be told to listen for gestures so that the app can respond to different touch interactions. This can be accomplished by creating the required gesture recognizers and adding them to the ARSCNView object with the AddGestureRecognizer method:

using ARKit;
using UIKit;
using CoreGraphics;
using SceneKit;

namespace ARKitDemo.Platforms.iOS;

public class MauiARView : UIView
{
    ARSCNView? _arView;
    ARSession? _arSession;
    UITapGestureRecognizer? _tapGesture;
    UIPinchGestureRecognizer? _pinchGesture;
    UIPanGestureRecognizer? _panGesture;
    ...

    public void StartARSession()
    {
        if (_arSession == null)
            return;

        _arSession.Run(new ARWorldTrackingConfiguration
        {
            AutoFocusEnabled = true,
            LightEstimationEnabled = true,
            PlaneDetection = ARPlaneDetection.None,
            WorldAlignment = ARWorldAlignment.GravityAndHeading
        }, ARSessionRunOptions.ResetTracking | ARSessionRunOptions.RemoveExistingAnchors);

        AddGestureRecognizers();
        AddContent();
    }

    void AddGestureRecognizers()
    {
        _tapGesture = new UITapGestureRecognizer(HandleTapGesture);
        _arView?.AddGestureRecognizer(_tapGesture);

        _pinchGesture = new UIPinchGestureRecognizer(HandlePinchGesture);
        _arView?.AddGestureRecognizer(_pinchGesture);

        _panGesture = new UIPanGestureRecognizer(HandlePanGesture);
        _arView?.AddGestureRecognizer(_panGesture);
    }
    ...
}

In this example, gesture recognizers are added for the tap, pinch, and pan gestures when the AR session starts. The following code example shows the methods that are used to process these gestures:

void HandleTapGesture(UITapGestureRecognizer? sender)
{
    SCNView? areaTapped = sender?.View as SCNView;
    CGPoint? location = sender?.LocationInView(areaTapped);
    SCNHitTestResult[]? hitTestResults = areaTapped?.HitTest((CGPoint)location!, new SCNHitTestOptions());
    SCNHitTestResult? hitTest = hitTestResults?.FirstOrDefault();

    SCNNode? node = hitTest?.Node;
    node?.RemoveFromParentNode();
    _arView?.RemoveGestureRecognizer(_tapGesture!);
    _arView?.RemoveGestureRecognizer(_pinchGesture!);
    _arView?.RemoveGestureRecognizer(_panGesture!);
}

void HandlePinchGesture(UIPinchGestureRecognizer? sender)
{
    SCNView? areaPinched = sender?.View as SCNView;
    CGPoint? location = sender?.LocationInView(areaPinched);
    SCNHitTestResult[]? hitTestResults = areaPinched?.HitTest((CGPoint)location!, new SCNHitTestOptions());
    SCNHitTestResult? hitTest = hitTestResults?.FirstOrDefault();

    if (hitTest == null)
        return;

    SCNNode node = hitTest.Node;
    float scaleX = (float)sender.Scale * node.Scale.X;
    float scaleY = (float)sender.Scale * node.Scale.Y;
    float scaleZ = (float)sender.Scale * node.Scale.Z;

    node.Scale = new SCNVector3(scaleX, scaleY, scaleZ);
    sender.Scale = 1;
}

void HandlePanGesture(UIPanGestureRecognizer? sender)
{
    SCNView? areaPanned = sender?.View as SCNView;
    CGPoint? location = sender?.LocationInView(areaPanned);
    SCNHitTestResult[]? hitTestResults = areaPanned?.HitTest((CGPoint)location!, new SCNHitTestOptions());
    SCNHitTestResult? hitTest = hitTestResults?.FirstOrDefault();

    SCNNode? node = hitTest?.Node;
    if (sender?.State == UIGestureRecognizerState.Changed)
    {
        CGPoint translate = sender.TranslationInView(areaPanned);
        node?.LocalTranslate(new SCNVector3((float)-translate.X / 10000, (float)-translate.Y / 10000, 0.0f));
    }        
}

All three methods share common code that determines the node on which a gesture was detected. Code that interacts with the node is then executed:

  • The HandleTapGesture method removes the node from the scene when it’s tapped, and then removes all of the gesture recognizers from the ARSCNView (there’s no need to listen for gestures when there isn’t a node present in the scene).
  • The HandlePinchGesture method scales the width and height of the node using a pinch gesture.
  • The HandlePanGesture method moves the node using a pan gesture.

The overall effect is that the image that’s been added to the scene can be removed, scaled, and moved within the scene. Additional gesture reognizers could also be added, for example rotation, swipes, and long presses.

Updated: