【SwiftUI】ARKitとRealityKitを活用し、特大のアート作品を部屋に置いてみた

2022.05.25

油絵やアクリル画などが見るのが好きなのですが、美術館に行って驚くのは作品の大きさに驚かされます。 いつかこんな大きな絵を部屋に置けたらなと思ったりしていたのですが、ARを使うことで簡単に実現できそうだなと思い試してみることにしました。

作ったもの

はじめに

以前、ARKitARSCNViewを使い平面検出をして物を置くというのは試したことがあったのですが、今回はARKitRealityKitARViewを使い試してみることにしました。

環境

  • Xcode 13.3
  • iPhone 12mini

ContentView

今回はUIViewRepresentableの ARViewContainerを表示させます。

import SwiftUI

struct ContentView: View {
    var body: some View {
        ARViewContainer()
            .ignoresSafeArea()
    }
}

ARViewContainer

ARViewをSwiftUIで使用する為に、UIViewRepresentableを作成します。

import RealityKit
import ARKit
import SwiftUI

struct ARViewContainer: UIViewRepresentable {

    func makeUIView(context: Context) -> ARView {

        let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)
        arView.addTapGesture()

        return arView
    }

    func updateUIView(_ uiView: ARView, context: Context) {}
}

makeUIView

ARViewを生成する関数で、今回はフレームが.zeroでカメラモードを.arにしています。.arにすることでARセッションによって管理されているデバイスのカメラを使用出来ます。

今回は特に特別な構成は使用しない為、automaticallyConfigureSessiontrueとしました。独自の構成でセッションを手動で実行する場合は、この値をfalseにします。デフォルトではこの値はtrueになっています。

そして、タップした時の処理を追加したいのでARViewarView.addTapGesture()UITapGestureRecognizerを追加しています。この関数の詳細は後ほど説明致します。

updateUIView

ARViewが更新された時に実行されるupdateUIViewは今回使用していないので中身は空になっています。

extension ARView

ARViewのエクステンションメソッドを作成します。

  • addTapGesture()
  • handleTap(recognizer: UITapGestureRecognizer)
  • getArtMaterial(name resourceName: String)
  • placeCanvas(at position: SIMD3)
extension ARView {

    func addTapGesture() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(recognizer:)))
        self.addGestureRecognizer(tapGesture)
    }

    @objc func handleTap(recognizer: UITapGestureRecognizer) {

        // タップしたロケーションを取得
        let tapLocation = recognizer.location(in: self)

        // タップした位置に対応する3D空間上の平面とのレイキャスト結果を取得
        let raycastResults = raycast(from: tapLocation, allowing: .estimatedPlane, alignment: .vertical)

        guard let firstResult = raycastResults.first else { return }
        // taplocationをワールド座標系に変換
        let position = simd_make_float3(firstResult.worldTransform.columns.3)

        placeCanvas(at: position)
    }

    /// キャンバスを配置する
    private func placeCanvas(at position: SIMD3<Float>) {

        guard let artTexture = getArtMaterial(name: "matsuda")
        else { return }

        let mesh = MeshResource.generateBox(width: 2, height: 3, depth: 0.15)
        let canvas = ModelEntity(mesh: mesh, materials: [artTexture])

        canvas.look(at: cameraTransform.translation, from: position, relativeTo: nil)

        let anchorEntity = AnchorEntity(world: position)
        anchorEntity.addChild(canvas)

        scene.addAnchor(anchorEntity)
    }

    /// アートマテリアルを取得する
    private func getArtMaterial(name resourceName: String) -> PhysicallyBasedMaterial? {

        guard let texture = try? TextureResource.load(named: resourceName)
        else { return nil }

        var imageMaterial = PhysicallyBasedMaterial()
        let baseColor = MaterialParameters.Texture(texture)
        imageMaterial.baseColor = PhysicallyBasedMaterial.BaseColor(tint: .white, texture: baseColor)
        return imageMaterial
    }
}

addTapGesture()

ARViewUITapGestureRecognizerを追加します。セレクターとしてhandleTap(recognizer:)を指定しています。

func addTapGesture() {
    let tapGesture = UITapGestureRecognizer(target: self,
                                            action: #selector(handleTap(recognizer:)))
    self.addGestureRecognizer(tapGesture)
}

handleTap(recognizer:)

実際にタップされた時に実行する処理になります。

@objc func handleTap(recognizer: UITapGestureRecognizer) {

    // タップしたロケーションを取得
    let tapLocation = recognizer.location(in: self)

    // タップした位置に対応する3D空間上の平面とのレイキャスト結果を取得
    let raycastResults = raycast(from: tapLocation, allowing: .estimatedPlane, alignment: .vertical)

    guard let firstResult = raycastResults.first else { return }
    // taplocationをワールド座標系に変換
    let position = simd_make_float3(firstResult.worldTransform.columns.3)

    placeCanvas(at: position)
}

タップしたロケーションを取得

まずはタップした箇所のロケーションを取得します

let tapLocation = recognizer.location(in: self)

タップした位置に対応するレイキャスト結果を取得

タップしたロケーションを介してレイキャストを実行します。レイキャスト(raycast)とは光(ray)を投げる(cast)という意味があり、投げた光の当たった結果を今回は取得しています。

@MainActor func raycast(from point: CGPoint, allowing target: ARRaycastQuery.Target, alignment: ARRaycastQuery.TargetAlignment) -> [ARRaycastResult]

引数は下記のようになっています。

  • from point: CGPoint
    • ビューのローカル座標系のポイント
  • allowing target: ARRaycastQuery.Target
    • 光線が終了するターゲットのタイプ、どの程度の平面を検出するか
      • case estimatedPlane ARKitが推定できる非平面のサーフェスまたは平面
      • case existingPlaneGeometry 平面が決定的なサイズと形状を持つことを必要とする
      • case existingPlaneInfinite サイズや形状に関係なく、検出された平面を指定する
  • alignment: ARRaycastQuery.TargetAlignment
    • ターゲットのalignment
    • case any 両方
    • case horizontal 水平
    • case vertical  垂直

今回は、垂直面の平面をターゲットにしたいので、ターゲットを.estimatedPlane、アライメントを.verticalにしています。

これによりタップした位置に対応する垂直面の平面の現実世界の位置を取得することが出来ます。

レイキャスト結果をワールド座標系のポジションに変換

guard let firstResult = raycastResults.first else { return }
// taplocationをワールド座標系に変換
let position = simd_make_float3(firstResult.worldTransform.columns.3)

placeCanvas(at: position)

ARRaycastResultとして、カメラから最も近いものから最も遠いものへとソートされたレイキャスト結果のリストが返ってくるので、その結果を先頭を取り出します。

worldTransform.columns.3からxyzの移動距離が取得できるので、その結果をSIMD3に変換しています。

キャンバスを配置する為に変換したポジションをplaceCanvas(at:)関数に渡しています。

キャンバスを配置する

private func placeCanvas(at position: SIMD3<Float>) {

    /// 3Dオブジェクトのマテリアル
    guard let artTexture = getArtMaterial(name: "matsuda")
    else { return }

    // 3Dオブジェクトのメッシュ
    let mesh = MeshResource.generateBox(width: 2, height: 3, depth: 0.15)

        // マテリアルとメッシュから物理オブジェクトを生成
    let canvas = ModelEntity(mesh: mesh, materials: [artTexture])

        // オブジェクトをカメラの方向に向ける
    canvas.look(at: cameraTransform.translation, from: position, relativeTo: nil)

    // アンカーを作成
    let anchorEntity = AnchorEntity(world: position)
    anchorEntity.addChild(canvas)

    scene.addAnchor(anchorEntity)
}

まず、後述するgetArtMaterial(name:)を使用してマテリアルを取得しています。

今回は、キャンバスっぽいものを表現したかったのでボックス型のメッシュを生成、サイズは幅2m、高さ3m、奥行き15cmとしました。

オブジェクトを配置した際に意図していない方向を向かないようにする為にlook(at:from:relativeTo:)を使用し、カメラの方向に向けることにしました。

最後に引数として渡された位置にアンカーを生成して、そのアンカーの子としてキャンバス型のオブジェクトを追加しています。そのアンカーをARViewscene上に追加することでキャンバスをAR空間上に配置することが出来ます。

オブジェクトに画像を貼り付ける

placeCanvas内でマテリアルを取得しているgetArtMaterial(name:)についての説明です。

private func getArtMaterial(name resourceName: String) -> PhysicallyBasedMaterial? {

    guard let texture = try? TextureResource.load(named: resourceName)
    else { return nil }

    var imageMaterial = PhysicallyBasedMaterial()
    let baseColor = MaterialParameters.Texture(texture)
    imageMaterial.baseColor = PhysicallyBasedMaterial.BaseColor(tint: .white, texture: baseColor)
    return imageMaterial
}

まずは、貼り付けたい画像をアセットフォルダに追加しておきます。

TextureResource.load(named:)を実行することで、バンドルにある画像をTextureResourceとして読み込みすることが出来ます。

あとは、そのテクスチャをマテリアルのbaseColorとして設定してあげると完成です。

おわりに

これで自分の部屋でも特大のアート作品の鑑賞を楽しむことが出来るようになりました!閲覧できるアート作品を増やしていき、楽しんでいきたいです。

今回、RealityKitを使用してみたのですが、ARKitARSCNViewを使う時とはまた勝手が違っており、新たな学びが沢山ありました。まだまだRealityKitの勉強が足りないので引き続き学んでいきたいと思います。また、LiDAR搭載のカメラがある方はもっと精度良く楽しめると思うので手に入れたい気持ちが高まりました。

モバイルアプリ開発のチームメンバー絶賛募集中!

モバイル事業部では事業会社様と一緒に、数年間にわたり長期でモバイルアプリをグロースさせています。

そんなモバイルアプリ開発のチームメンバーを絶賛募集中です!

もちろんモバイルアプリ開発以外のエンジニアも募集中です!

クラスメソッド採用サイト

参考