Thursday, 18 March 2021

Adventures in ARKit - animation

In my previous blog post I discussed how to interact with nodes in a scene, using touch. This involved creating gesture recognisers and adding them to the ARSCNView instance with the AddGestureRecognizer method.

In this blog post I’ll examine animating a node in a scene. I originally wanted to animate a sphere, to make it rotate. However, it can be difficult to observe a sphere with a diffuse colour rotating. Therefore, I switched to rotating a cube.

The sample this code comes from can be found on GitHub.

Animate a node

In order to add a cube to the scene, I created a CubeNode type that derives from SCNNode:

using SceneKit;
using UIKit;

namespace ARKitFun.Nodes
{
    public class CubeNode : SCNNode
    {
        public CubeNode(float size, UIColor color)
        {
            SCNMaterial material = new SCNMaterial();
            material.Diffuse.Contents = color;

            SCNBox geometry = SCNBox.Create(size, size, size, 0);
            geometry.Materials = new[] { material };

            SCNNode node = new SCNNode
            {
                Geometry = geometry
            };

            AddChildNode(node);
        }
    }
}

The CubeNode constructor takes a float argument that represents the size of each side of the cube, and a UIColor argument that represents the colour of the cube. The constructor creates the materials and geometry for the cube, before creating a SCNNode and assigning the geometry to its Geometry property, and adds the node as a child node to the SCNNode.

Nodes can be animated with the SCNAction type, which represents a reusable animation that changes attributes of any node you attach it to. SCNAction objects are created with specific class methods, and are executed by calling a node object’s RunAction method, passing the action object as an argument.

For example, the following code creates a rotate action and applies it to a CubeNode:

SCNAction rotateAction = SCNAction.RotateBy(0, (float)Math.PI, 0, 5); // X,Y,Z,secs
CubeNode cubeNode = new CubeNode(0.1f, UIColor.Blue);
cubeNode.RunAction(rotateAction);
sceneView.Scene.RootNode.AddChildNode(cubeNode);

In this example, the CubeNode is rotated 360 degrees on the Y axis over 5 seconds. To rotate the cube indefinitely, use the following code:

SCNAction rotateAction = SCNAction.RotateBy(0, (float)Math.PI, 0, 5);
SCNAction indefiniteRotation = SCNAction.RepeatActionForever(rotateAction);
CubeNode cubeNode = new CubeNode(0.1f, UIColor.Blue);
cubeNode.RunAction(indefiniteRotation);
sceneView.Scene.RootNode.AddChildNode(cubeNode);

In this example, the CubeNode is rotated 360 degrees on the Y axisover 5 seconds. That is, it takes 5 seconds to complete a full 360 degree rotation. Then the animation is looped.

This code can be generalised into an extension method that can be called on any SCNNode type:

using System;
using SceneKit;

namespace ARKitFun.Extensions
{
    public static class SCNNodeExtensions
    {
        public static void AddRotationAction(this SCNNode node, SCNActionTimingMode mode, double secs, bool loop = false)
        {
            SCNAction rotateAction = SCNAction.RotateBy(0, (float)Math.PI, 0, secs);
            rotateAction.TimingMode = mode;

            if (loop)
            {
                SCNAction indefiniteRotation = SCNAction.RepeatActionForever(rotateAction);
                node.RunAction(indefiniteRotation, "rotation");
            }
            else
                node.RunAction(rotateAction, "rotation");
        }
    }
}

The AddRotationAction extension method adds a rotate animation to the specified SCNNode. The SCNActionTimingMode argument defines the easing function for the animation. The secs argument defines the number of seconds to complete a full rotation of the node, and the loop argument defines whether to animate the node indefinitely. The RunAction method calls both specify a string key argument. This enables the animation to be stopped programmatically by specifying the key as an argument to the RemoveAction method.

The ViewDidAppear method in the ViewController class can then be modified to add a CubeNode to the scene, and animate it:

using System;
using System.Linq;
using ARKit;
using ARKitFun.Extensions;
using ARKitFun.Nodes;
using CoreGraphics;
using SceneKit;
using UIKit;

namespace ARKitFun
{
    public partial class ViewController : UIViewController
    {
        readonly ARSCNView sceneView;
        const float size = 0.1f;
        const float zPosition = -0.5f;
        bool isAnimating;        
        ...
        
        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);

            sceneView.Session.Run(new ARWorldTrackingConfiguration
            {
                AutoFocusEnabled = true,
                LightEstimationEnabled = true,
                PlaneDetection = ARPlaneDetection.Horizontal,
                WorldAlignment = ARWorldAlignment.Gravity
            }, ARSessionRunOptions.ResetTracking | ARSessionRunOptions.RemoveExistingAnchors);

            CubeNode cubeNode = new CubeNode(size, UIColor.Blue);
            cubeNode.Position = new SCNVector3(0, 0, zPosition);

            sceneView.Scene.RootNode.AddChildNode(cubeNode);

            UITapGestureRecognizer tapGestureRecognizer = new UITapGestureRecognizer(HandleTapGesture);
            sceneView.AddGestureRecognizer(tapGestureRecognizer);
            ...
        }
        ...

In this example, a blue CubeNode is created and positioned in the scene at (0,0,-0.5). In addition, a UITapGestureRecognizer is added to the scene.

The following code example shows the HandleTapGesture method:

void HandleTapGesture(UITapGestureRecognizer sender)
{
    SCNView areaPanned = sender.View as SCNView;
    CGPoint point = sender.LocationInView(areaPanned);
    SCNHitTestResult[] hitResults = areaPanned.HitTest(point, new SCNHitTestOptions());
    SCNHitTestResult hit = hitResults.FirstOrDefault();

    if (hit != null)
    {
        SCNNode node = hit.Node;
        if (node != null)
        {
            if (!isAnimating)
            {
                node.AddRotationAction(SCNActionTimingMode.Linear, 3, true);
                isAnimating = true;
            }
            else
            {
                node.RemoveAction("rotation");
                isAnimating = false;
            }
        }                    
    }
}

In this example, the node on which the tap gesture was detected is determined. If the node isn’t being animated, an indefinite rotation SCNAction is added to the node, which fully rotates the node every 3 seconds. Then, provided that the node is being animated, when it’s tapped again the animation ceases by calling the RemoveAction method, specifying the key value for the action.

The overall effect is that when the app runs, tapping on the node animates it. When animated, tapping on the node stops the animation. Then a new animation will begin on the subsequent tap:

As I mentioned at the beginning of this blog post, I originally wanted to rotate a sphere, with a view to creating a rotating earth. However, it can be difficult to see a sphere with a diffuse colour rotating.

In my next blog post I’ll discuss rotating a sphere to create a rotating earth.

No comments:

Post a Comment