ARのSNSを作ってみる オブジェクトの操作（移動・回転）編

# 初めに
　前回の記事の終わりに「次回はオブジェクトの絶対位置取得や永続化をする」と記載しましたが、オブジェクトの操作（移動・回転）が思ったより苦戦したので記事にします。

 https://qiita.com/dd7223dd/items/fe3bb531866872ff57a8

　ARCoreの公式ドキュメントに「コンテンツ操作」としてオブジェクトの移動・回転について触れてはいるのですが、具体的なやり方の説明がなく、その他のネット記事にも私が調べた限りだと見当たらなかったので備忘録として残しておきます。sceneformだとオブジェクトの操作が提供されているらしいのですが、開発が終了しているので今回は使いません。なので今回紹介する以外のやり方があったら教えて欲しいです。
 
 https://developers.google.com/ar/design/content/content-manipulation?hl=ja

# オブジェクトの操作の基本的な考え
　オブジェクトは「アンカー」というものに紐づく形で描写されます。以下のページからも分かる通り、アンカーに**移動**や**回転**といった操作は提供されていません。つまり、アンカーというのは一度置いたらその場に固定され、操作不可になります。

 https://developers.google.com/ar/reference/java/com/google/ar/core/Anchor

 　上記のことからオブジェクトの移動・回転はアンカーの作成と削除を繰り返して表現します。この作成・削除を繰り返す処理を毎フレーム実行されるGLSurfaceView.RendererのonDrawFrame内に置くことで、滑らかな操作が可能になります。

# オブジェクトの移動
　オブジェクトの移動はスワイプ操作をしたら、その指の動きに合わせてオブジェクトが移動するという仕様にしました。
 
　まずはスワイプ処理の取得を行います。取得の方法は何でもよくて、スワイプ操作中のX,Yが取れれば良いです。私は以下のようにしました。onScrollがスクロース操作を取得するメソッドでe1が開始時点のMotionEvent、e2が現在のMotionEvent、distanceX、distanceYがそれぞれ前回からの移動距離です。スクロールされた現在位置に対してオブジェクト作成すればよいので、e2のみ利用します。

```kotlin
import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public final class TapHelper implements OnTouchListener {
  private final GestureDetector gestureDetector;
  private final BlockingQueue<MotionEvent> queuedLongPress = new ArrayBlockingQueue<>(16);
  private final BlockingQueue<MotionEvent> queuedSwipes = new ArrayBlockingQueue<>(16);

  public TapHelper(Context context) {
    gestureDetector =
        new GestureDetector(
            context,
            new GestureDetector.SimpleOnGestureListener() {
              @Override
              public void onLongPress(MotionEvent e) {
                  queuedLongPress.offer(e);
              }

              @Override
              public boolean onScroll( MotionEvent e1,  MotionEvent e2, float distanceX, float distanceY) {
                  queuedSwipes.offer(e2);
                  return true;
              }

              @Override
              public boolean onDown(MotionEvent e) {
                return true;
              }
            });
  }

  public MotionEvent poll() {
    return queuedLongPress.poll();
  }

  public MotionEvent swipePoll() {
      return queuedSwipes.poll();
  }

  @Override
  public boolean onTouch(View view, MotionEvent motionEvent) {
    return gestureDetector.onTouchEvent(motionEvent);
  }
}
```

　次にアンカーの更新処理を行います。「currentWrappedAnchor」は現在選択中のアンカーを表しているクラス変数です。先ほど紹介したtapHelperからMotionEventを取得して、ヒットテスト（平面とか点群とかにヒットしたかのチェック）を行います。ヒットテストの結果から新しいアンカーを作成してcurrentWrappedAnchorに代入します。

```kotlin
override fun onDrawFrame(render: SampleRender) {
    //諸々
    handleSwipe(frame, camera)
    //currentWrappedAnchorの描写処理とか
}

private fun handleSwipe(frame: Frame, camera: Camera) {
    if (camera.trackingState != TrackingState.TRACKING) return
    if (currentWrappedAnchor == null) return
    val swipe = activity.view.tapHelper.swipePoll() ?: return

    val hitResultList =
        if (activity.instantPlacementSettings.isInstantPlacementEnabled) {
            frame.hitTestInstantPlacement(swipe.x, swipe.y, APPROXIMATE_DISTANCE_METERS)
        } else {
            frame.hitTest(swipe.x, swipe.y)
        }

    val firstHitResult =
        hitResultList.firstOrNull { hit ->
            when (val trackable = hit.trackable!!) {
                is Plane ->
                    trackable.isPoseInPolygon(hit.hitPose) &&
                            PlaneRenderer.calculateDistanceToPlane(hit.hitPose, camera.pose) > 0
                is Point -> trackable.orientationMode == Point.OrientationMode.ESTIMATED_SURFACE_NORMAL
                is InstantPlacementPoint -> true
                is DepthPoint -> true
                else -> false
            }
        }

    if (firstHitResult != null) {
        currentWrappedAnchor = WrappedAnchor(firstHitResult.createAnchor(), firstHitResult.trackable)
    }
}
```

　これでオブジェクトが指の操作に合わせて移動するようになります。

![Videotogif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267761/79ea6336-648c-8f10-3483-80659003718c.gif)


 # オブジェクトの回転
 　オブジェクトの回転は2本指でスワイプしたら、その指の動きに合わせてオブジェクトが回転する仕様にしました。

　オブジェクトの移動同様2本指のスワイプ処理の取得を行います。先ほどのonScrollメソッドに指が2本の場合は前回からの移動距離を保存する処理を追加しました。

```kotlin
@Override
public boolean onScroll( MotionEvent e1,  MotionEvent e2, float distanceX, float distanceY) {
    int pointerCount = e2.getPointerCount();
    if (pointerCount == 2) {
        Map<String, Float> map = new HashMap<>();
        map.put("x",distanceX);
        map.put("y",distanceY);
        queuedTwoFingerSwipes.offer(map);
    } else {
        queuedSwipes.offer(e2);
    }
    return true;
}
```

　次にアンカーの更新処理を行います。得られた移動量からそれぞれ回転の角度を求めて、アンカーの向きを調整して再生成します。

```kotlin
override fun onDrawFrame(render: SampleRender) {
    //諸々
    handleTwoFingerSwipe(camera)
    //currentWrappedAnchorの描写処理とか
}

private fun handleTwoFingerSwipe(camera: Camera) {
    if (camera.trackingState != TrackingState.TRACKING) return
    if (currentWrappedAnchor == null) return
    val twoFingerSwipe = activity.view.tapHelper.twoFingerSwipePoll() ?: return

    val rotationAngleX = twoFingerSwipe["x"].toString().toFloat() * 0.5f
    val rotationAngleY = twoFingerSwipe["y"].toString().toFloat() * 0.5f
    
    val quaternionX = Quaternion.axisAngle(Vector3(0f, 0f, 1f), rotationAngleX)
    val quaternionY = Quaternion.axisAngle(Vector3(-1f, 0f, 0f), rotationAngleY)
    // X軸回転とY軸回転を合成
    val quaternion  = Quaternion.slerp(quaternionX, quaternionY, 0.5f)
    val currentPose = currentWrappedAnchor!!.anchor.pose
    val newPose = currentPose.compose(Pose.makeRotation(quaternion.x, quaternion.y, quaternion.z, quaternion.w))
    currentWrappedAnchor =
        session?.let { WrappedAnchor(it.createAnchor(newPose), currentWrappedAnchor!!.trackable) }
}
```

　これでオブジェクトが指の操作に合わせて回転するようになります。

![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267761/4599ae2f-ab1e-3ba0-4a6c-aa2e3b90a868.gif)

 # まとめ
 　今回はここまでにします。似たようなことでお困りの方のお役に立てれば幸いです。