某アニメのEDが格好良かったので、AR初心者が実装してみた。

「さらざんまい」（2019年春アニメ）面白かったですね！ 相変わらず人類には早すぎるアニメでしたが、EDがアニメと実写とCGを組み合わせたとても格好良いものになってました。

というわけで、あの格好良いEDをARで実現できないかやってみました。

# iOSでAR

ARとかVRってなんか敷居が高くて手が出ないのですが、iOS には ARKit という ARアプリ を作成する上で便利な SDK があるので、これを利用します。

今回実装のためにいろいろ調べたところ、どうやら iOS で AR アプリを作成する場合は、次の２つの SDK を利用するっぽいみたいです。

　　ARKit  --------  主に空間認識を行う
　　ScnenKit  ----  主に３D描画を行う

ARKit で空間認識を行って、ScnenKit でその認識の上に何がしかを描く、という役割のようです。この２つを上手く組み合わせて実空間に仮想の物体を重ね合わせます。


## したいこと

商店街をバックに光の輪やブロックがキラキラする、あれです。あれをやりたいのです。
そのためには、とりあえず、水平面を検出して、それに垂直な面にキラキラなアニメーションを貼り付ける、ということをやります。


## 水平面の検出

よいサンプルがあったので、これらを基本に作っていきます。
　→ https://blog.markdaws.net/arkit-by-example-part-2-plane-detection-visualization-10f05876d53
　→ https://github.com/rajubd49/ARKit-Sample-ObjC

今回のソースは Github にアップしましたので、そちらをご覧ください。
　→ https://github.com/ysomei/SaraARSample

ポイントだけを書いていきます。
ファイル構成
　　ViewController　　　　メインのクラス
　　ARSCNViewController　AR処理用のクラス
　　AlertViewController　　アラート表示用のクラス　←　ソースを見ればわかると思うので説明は割愛

## ViewControllerクラス
　メインのクラスです。ここで初期設定等をしています。

### 動作設定など

```objc
- (void)setupScene {
    self.sceneView.delegate = self.sceneController;
    self.sceneNode = [NSMutableArray new];
    self.sceneView.showsStatistics = YES;
    self.sceneView.autoenablesDefaultLighting = YES;
    self.sceneView.debugOptions = SCNDebugOptionNone;
    //self.sceneView.debugOptions = ARSCNDebugOptionShowWorldOrigin | ARSCNDebugOptionShowFeaturePoints;
    
    SCNScene *scene = [SCNScene new];
    self.sceneView.scene = scene;
}
```

`self.sceneView` は ARSCNView クラスでヘッダーで定義しています。これが AR（空間認識） の本体みたいなものです。これに対して delegate や session を指定していきます。
`self.scnenView.debugOptions = SCNDebugOptionNone;` は検出状態を表示するオプションです。下のコメント行と入れ替えると検出状態がわかります。


```objc
- (void)setupGestureRecognizer {
    // tap gesture
    UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapScreen:)];
    tapGesture.numberOfTapsRequired = 1;
    [self.sceneView addGestureRecognizer:tapGesture];
    
    // long press gesture
    UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressScreen:)];
    longPress.minimumPressDuration = 0.5;
    [self.sceneView addGestureRecognizer:longPress];
}
```

ジェスチャーを登録しています。今回はタップとロングプレスの２種類を取得して処理をします。
タップの場合は、円が広がるアニメ、ロングプレスの場合は四角がキラキラして上昇するアニメに対応づけます。
ポイントは、呼び出した関数側にあります。

```objc
- (void)handleTapScreen:(UITapGestureRecognizer *)recognizer {
    CGPoint tapPoint = [recognizer locationInView:self.sceneView];
    NSArray <SCNHitTestResult *>*result = [self.sceneView hitTest:tapPoint options:@{SCNHitTestBoundingBoxOnlyKey: @YES, SCNHitTestFirstFoundOnlyKey: @YES}];
    if(result.count == 0){
        NSLog(@"new tap!");
    } else {
        SCNHitTestResult *tres = [result firstObject];
        //NSLog(@"name: %@", tres.node.name);
        NSLog(@"taps: x: %f  y: %f  z: %f", tres.worldCoordinates.x, tres.worldCoordinates.y, tres.worldCoordinates.z);
        
        SCNNode *node = [self animatedCircle:tres.node];
        [tres.node addChildNode:node];
    }
}
```

`tapPoint` は現在の表示画面からどこがタップされたかを取得しています。
それを次の
`NSArray <SCNHitTestResult *>*result = [self.sceneView hitTest:tapPoint options:@{SCNHitTestBoundingBoxOnlyKey: @YES, SCNHitTestFirstFoundOnlyKey: @YES}];`
で AR上（三次元空間上）でどこになるかを解析して返しています。
何か返ってきたら（`result.count != 0`なら）その座標を持つノードにアニメーションを追加します。

ロングプレスの場合も同様にします。

### アニメーションの追加
これが一番苦労しました。

追加するオブジェクトは `SCNScene` に `SCNNode` として追加します。
最初は、透明オブジェクトを追加して、パーティクル（ARオブジェクトに対する特殊効果：オブジェクトの周りでキラキラしたりメラメラしたりゆらゆらしたりするあれ）で実現できるのではないかと、いろいろ調べたりやってもみましたが、パーティクルの場合、カメラに追随してしまい思った動作にならないことがわかりました。

次に `SCNPlane`クラスという平面を返すオブジェクトがあることがわかったので、これに画像を貼り付けてみましたが、アニメーションをどうやってするかがわかりませんでした。
ノードに対してのアニメーションは設定できるのですが、ノード内の画像に対してのアニメーションは SCNNode では定義できません。

そしていろいろ調べてたどり着いたのがこれでした。

```objc
// アニメーション付き円
- (SCNNode *)animatedCircle:(SCNNode *)node {
    SCNMaterial *material = [SCNMaterial new];
    material.diffuse.contents = [self create2DCircleWithAnime:node];
    
    SCNPlane *pln = [SCNPlane planeWithWidth:2.0 height:2.0];
    pln.materials = @[material];
    SCNNode *pnode = [SCNNode nodeWithGeometry:pln];
    return pnode;
}

- (UIImageView *)create2DCircleWithAnime:(SCNNode *)node {
    NSMutableArray *iary = [[NSMutableArray alloc] init];
    [iary removeAllObjects];
    for(int i = 0; i < 15; i++){
        float rad = (150.0 / 15) * (i + 1);
        UIImage *img = [self circleImg:rad];
        [iary addObject:img];
    }
    UIImage *animeImg = [UIImage animatedImageWithImages:iary duration:0.75];
    UIImageView *imgview = [[UIImageView alloc] initWithImage:animeImg];
    imgview.animationDuration = 1.0;
    imgview.animationRepeatCount = 1; //INFINITY;
    [self performSelector:@selector(animationDidFinish:) withObject:node afterDelay:0.75];
    return imgview;
}

// 円を描く
- (UIImage *)circleImg:(float)radius {
    // 描画用イメージ作成
    CGRect rect = CGRectMake(0, 0, 300.0, 300.0); // unit -> pixel
    UIGraphicsBeginImageContext(rect.size);
    CGContextRef context = UIGraphicsGetCurrentContext();
    //UIGraphicsPushContext(context); // ← これは要らないっぽい
    
    // 描画！
    UIBezierPath *circle = [UIBezierPath bezierPathWithArcCenter:CGPointMake(150.0, 270.0) radius:radius startAngle:0 endAngle:M_PI * 2 clockwise:YES];
    [[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0] setStroke];
    [circle setLineWidth:1.0];
    [circle stroke];
    // 描いた絵を UIImage に変換（描画用イメージを終了）
    CGContextAddPath(context, circle.CGPath);
    UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
    //UIGraphicsPopContext(); // ← これは要らないっぽい
    UIGraphicsEndImageContext();
    
    return img;
}
```

分かれば簡単なんですけどね。
アニメーションのイメージを作成して、それを SCNPlane オブジェクトに貼り付けて、ノードに追加すればいいのです。

ちなみに、何もしなければ無限に繰り返すので、 `[self performSelector:@selector(animationDidFinish:) withObject:node afterDelay:0.75];` で一回アニメーションを実行する時間を設定して、ノードから削除するようにして、一回きりの表示にしています。
`imgview.animationRepeatCount = 1;`が効いていない感じ？

```objc
- (void)animationDidFinish:(id)sender {
    NSLog(@"animation did finish");
    SCNNode *node = (SCNNode *)sender;
    [node.childNodes.firstObject removeFromParentNode];
}
```

で、同様に四角いキラキラアニメも作成して、追加するようにします。
四角いキラキラアニメはアニメ作成に時間がかかるので、最初に作成して、ロングプレスされたら、それを追加するようにしました。

### ARセッション開始

```objc
- (void)viewWillAppear:(BOOL)animated {
    if (ARWorldTrackingConfiguration.isSupported) {
        NSLog(@"AR World Tracking supported!");
        [self startSession];
        
    } else {
        NSLog(@"No supported AR World Tracking...");
        [self.alertController showUnsupportedAlert];
    }
}

- (void)startSession {
    ARWorldTrackingConfiguration *configuration = [ARWorldTrackingConfiguration new];
    configuration.planeDetection = ARPlaneDetectionHorizontal; // 水平面検知
    [self.sceneView.session runWithConfiguration:configuration];
    
    [self checkMediaPermission];
}
```

ポイントは `configuration.planeDetection = ARPlaneDetectionHorizontal;` です。ここで水平面の検出を指定しています。
水平面を検出すると、`- (void)setupScene` で設定した delegate が呼ばれます。

## ARSCNViewControlllerクラス

水平面が検出されたら次の関数が呼ばれます。

```objc
// ノードが追加されました。
- (void)renderer:(id<SCNSceneRenderer>)renderer didAddNode:(SCNNode *)node forAnchor:(ARAnchor *)anchor {
    if (![anchor isKindOfClass:[ARPlaneAnchor class]]) return;
    
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"Surface detected!");
        [self.alertController showOverlyText:@"Surface detected!" withDuration:1];
    });
    
    // add empty image :p
    SCNNode *enode = [self cerateEmptyPlane];
    [node addChildNode:enode];
}
```

真ん中の `dispath_async( ... );` はアラート表示用です。
タップやロングプレスをしたときに、ARとして認識するために、透明のオブジェクトを追加しています。
タップやロングプレスをしたときに、その座標がこの透明オブジェクト内にあれば、アニメーションを表示する、ということになります。


## 出来上がり
こんな感じになりました。
暗くて見えにくいかもしれませんが、一応円が広がるアニメは地面で切れるようになっています。
まあこれは単純に円の中心点を下の方に設定して円を描画領域外にまで広げているだけです。
スケール感は、、、まあ、後で調整しましょう。。。

![SaraSampleMovie.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59523/698e88ef-b08c-5f3f-a7b0-99632f342b4d.gif)


## 所感

AR 取っ付きにくかったですが、構成（ARKit で空間認識、SceneKit で3D表示）が分かればなんか出来そうな気がしてきました。
いろいろと遊んでみたいと思います。