【Vision Pro】Hand Tracking でジェスチャーを検知する

2024/07/27に公開

初めに

今回は Apple Vision Pro の Hand Tracking を用いて、ユーザーのジェスチャーを検知する実装を行いたいと思います。ジェスチャーの検知ができれば、ユーザーが特定のジェスチャーをした際に何らかのフィードバックを与えることができるようになり、ユーザーとしてもより体験に没入できるようになります。

記事の対象者

  • Swift 学習者
  • Vision Pro でハンドジェスチャーを用いた実装をしたい方

目的

今回は先述の通り、Apple Vision Pro の Hand Tracking を用いて、ユーザーのジェスチャーを検知する実装を行いたいと思います。最終的には以下の動画のように伸ばしている指の本数をもとに足し算を行うような実装を行いたいと思います。

https://youtu.be/VaBKzB6khKQ

また、今回作成したプロジェクトは以下のGitHubにあげているので、よろしければご覧ください。
https://github.com/Koichi5/vision-os-hand-tracking

実装

実装は以下のステップで進めていきたいと思います。

  1. プロジェクトの作成、設定
  2. ViewModelの実装
  3. Viewの実装
  4. Appの実装

1. プロジェクトの作成、設定

プロジェクトを作成する際は以下のような項目を設定しておきます。

  • Initial Scene: Window
  • Immersive Space Renderer: RealityKit
  • Immersive Space: Mixed

次にハンドトラッキングを使用するために、 info.plist に以下のような指定をしておきます。
これで、ハンドトラッキングが必要になった段階で自動的にユーザーの許可を求めるダイアログが表示されるようになります。

NSHandsTrackingUsageDescription: String: 指の動きを検知するためにハンドトラッキングを使用します

2. ViewModelの実装

まずは ViewModel を実装していきます。
ViewModel 側では、ハンドジェスチャーを使用するために必要な機能を実装します。
コード全文は長いので折りたたんでおきます。

ViewModelのコード全文
import SwiftUI
import RealityKit
import ARKit

enum HandGesture {
    case notTracked
    case closed
    case custom(Int)
}

enum Hands {
    case left
    case right
}

enum Fingers {
    case thumb
    case index
    case middle
    case ring
    case little
    case wrist
}

enum JointType {
    case tip
    case pip
    case dip
    case mcp
}

@MainActor
class HandTrackingViewModel: ObservableObject {
    @Published var leftHandGesture: HandGesture = .notTracked
    @Published var rightHandGesture: HandGesture = .notTracked
    @Published var displayedNumber: Int = 0
    
    private let session = ARKitSession()
    private var leftHandAnchor: HandAnchor?
    private var rightHandAnchor: HandAnchor?
    private var handTrackingProvider: HandTrackingProvider?

    static let shared = HandTrackingViewModel()
    
    init() {
        handTrackingProvider = HandTrackingProvider()
    }
    
    func startHandTracking() async {
        do {
            try await session.run([handTrackingProvider!])
            await handleHandUpdates()
        } catch {
            print("Failed to start session: \(error)")
        }
    }
    
    private func handleHandUpdates() async {
        for await update in handTrackingProvider!.anchorUpdates {
            let handAnchor = update.anchor
            
            guard handAnchor.isTracked else {
                continue
            }
            
            if handAnchor.chirality == .left {
                self.leftHandAnchor = handAnchor
                self.leftHandGesture = self.determineHandGesture(hand: .left)
            } else {
                self.rightHandAnchor = handAnchor
                self.rightHandGesture = self.determineHandGesture(hand: .right)
            }
            self.updateDisplayedNumber()
        }
    }
    
    func determineHandGesture(hand: Hands) -> HandGesture {
        let fingers: [Fingers] = [.thumb, .index, .middle, .ring, .little]
        let extendedFingers = fingers.filter { isStraight(hand: hand, finger: $0) }
        
        if extendedFingers.isEmpty {
            return .closed
        } else {
            return .custom(extendedFingers.count)
        }
    }
    
    private func updateDisplayedNumber() {
        _ = displayedNumber
        displayedNumber = [leftHandGesture, rightHandGesture].reduce(0) { total, gesture in
            switch gesture {
            case .custom(let count):
                return total + count
            default:
                return total
            }
        }
    }
    
    private func extractPosition2D(_ transform: simd_float4x4?) -> CGPoint? {
        guard let transform = transform else { return nil }
        let position = transform.columns.3
        return CGPoint(
            x: CGFloat(position.x),
            y: CGFloat(position.y)
        )
    }
    
    private func isStraight(hand: Hands, finger: Fingers) -> Bool {
        if finger == .thumb {
            return isThumbExtended(hand: hand)
        }
        
        guard let tipPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .tip)),
              let secondPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .pip)),
              let posWrist = extractPosition2D(jointPosition(hand: hand, finger: .wrist, joint: .tip)) else {
            return false
        }

        let tipToWristDistance = posWrist.distance(to: tipPosition)
        let secondToWristDistance = posWrist.distance(to: secondPosition)
        
        return secondToWristDistance < tipToWristDistance * 0.9
    }

    private func isThumbExtended(hand: Hands) -> Bool {
        guard let thumbTipPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .tip)),
              let thumbIPPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .pip)),
              let thumbCMCPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .mcp)) else {
            return false
        }

        let distalSegmentLength = thumbIPPosition.distance(to: thumbTipPosition)
        let proximalSegmentLength = thumbCMCPosition.distance(to: thumbIPPosition)
        
        let extensionThreshold = 1.2
        return distalSegmentLength > proximalSegmentLength * extensionThreshold
    }
    
    private func jointPosition(hand: Hands, finger: Fingers, joint: JointType) -> simd_float4x4? {
        let anchor = hand == .left ? leftHandAnchor : rightHandAnchor
        guard let skeleton = anchor?.handSkeleton else { return nil }

        let jointName: HandSkeleton.JointName
        switch (finger, joint) {
        case (.thumb, .tip): jointName = .thumbTip
        case (.thumb, .pip): jointName = .thumbIntermediateBase
        case (.thumb, .mcp): jointName = .thumbIntermediateTip
        case (.index, .tip): jointName = .indexFingerTip
        case (.index, .pip): jointName = .indexFingerIntermediateBase
        case (.middle, .tip): jointName = .middleFingerTip
        case (.middle, .pip): jointName = .middleFingerIntermediateBase
        case (.ring, .tip): jointName = .ringFingerTip
        case (.ring, .pip): jointName = .ringFingerIntermediateBase
        case (.little, .tip): jointName = .littleFingerTip
        case (.little, .pip): jointName = .littleFingerIntermediateBase
        case (.wrist, .tip): jointName = .wrist
        default: return nil
        }

        return skeleton.joint(jointName).anchorFromJointTransform
    }
}

extension CGPoint {
    func distance(to point: CGPoint) -> CGFloat {
        return sqrt(pow(x - point.x, 2) + pow(y - point.y, 2))
    }
}

ViewModel の実装は以下に分けられます。

  • ハンドジェスチャーに必要な enum の定義
  • ViewModel内の変数と初期化処理の定義
  • ハンドトラッキングの開始処理
  • 手の動きの更新処理
  • ハンドジェスチャーの検知
  • それぞれの指が伸びているかどうかの判定処理
  • CGPoint の extension

それぞれみていきます。

以下ではハンドジェスチャーに必要な enum の定義をしています。
それぞれの enum はコメントで書いている通りです。

// ハンドジェスチャーの状態
// トラックされていない or 手が閉じられている or 指が伸ばされている数
enum HandGesture {
    case notTracked 
    case closed
    case custom(Int)
}

// 左右の手の enum
enum Hands {
    case left
    case right
}

// それぞれのての指の enum
enum Fingers {
    case thumb
    case index
    case middle
    case ring
    case little
    case wrist
}

// それぞれの指の関節の enum
enum JointType {
    case tip
    case pip
    case dip
    case mcp
}

以下ではViewModel内の変数と初期化処理の定義を行っています。
初期状態で HandGesture.notTrackeddisplayedNumber0HandAnchor はハンドトラッキングがされていないため、定義のみにしてあります。
sharedHandTrackingViewModel() を定義して外部からも使用しやすいように保持しておきます。

init() では初期化処理として、ハンドトラッキングを行うための HandTrackingProvider をインスタンス化しておきます。

@Published var leftHandGesture: HandGesture = .notTracked
@Published var rightHandGesture: HandGesture = .notTracked
@Published var displayedNumber: Int = 0
    
private let session = ARKitSession()
private var leftHandAnchor: HandAnchor?
private var rightHandAnchor: HandAnchor?
private var handTrackingProvider: HandTrackingProvider?

static let shared = HandTrackingViewModel()
    
init() {
    handTrackingProvider = HandTrackingProvider()
}

以下ではハンドトラッキングの開始処理を記述しています。
ARKitSessionrun メソッドで handTrackingProvider を指定することでハンドトラッキングを有効化しています。
そのほかにも後述しますが、手の位置を認識して位置が更新された際に実行する handleHandUpdates メソッドを実行しています。

func startHandTracking() async {
    do {
        try await session.run([handTrackingProvider!])
        await handleHandUpdates()
    } catch {
        print("Failed to start session: \(error)")
    }
}

以下のコードでは、手の動きの更新処理を実装しています。
手の動きの変化は handTrackingProvideranchorUpdates から取得することができます。
anchorUpdates からfor文で update を取り出して、その anchor をもとに値の変更を行います。
handAnchorchirality で右手左手の判定を行い、それぞれの handAnchor を割り当てていきます。

private func handleHandUpdates() async {
    for await update in handTrackingProvider!.anchorUpdates {
        let handAnchor = update.anchor
         
        guard handAnchor.isTracked else {
            continue
        }
            
        if handAnchor.chirality == .left {
            self.leftHandAnchor = handAnchor
            self.leftHandGesture = self.determineHandGesture(hand: .left)
        } else {
            self.rightHandAnchor = handAnchor
            self.rightHandGesture = self.determineHandGesture(hand: .right)
        }
        self.updateDisplayedNumber()
    }
}

以下ではジェスチャーの判定と表示させる数字の更新のメソッドを実装しています。
determineHandGesture メソッドでは、ジェスチャーの判定を行なっています。
後述の isStraight を用いて特定の指が伸びているかどうかを判定し、その値に応じて HandGesture の値を返しています。

updateDisplayedNumber メソッドでは、ハンドジェスチャーに応じて画面に表示させる数字を更新しています。例えば、右手はグーで左手をパーにしておくと、伸びている指の本数は5本なので、 displayedNumber5 に設定されます。

func determineHandGesture(hand: Hands) -> HandGesture {
    let fingers: [Fingers] = [.thumb, .index, .middle, .ring, .little]
    let extendedFingers = fingers.filter { isStraight(hand: hand, finger: $0) }
        
    if extendedFingers.isEmpty {
        return .closed
    } else {
        return .custom(extendedFingers.count)
    }
}
    
private func updateDisplayedNumber() {
    _ = displayedNumber
    displayedNumber = [leftHandGesture, rightHandGesture].reduce(0) { total, gesture in
        switch gesture {
            case .custom(let count):
                return total + count
            default:
                return total
        }
    }
}

以下ではそれぞれの指が伸びているかどうかの判定処理を実装しています。
extractPosition2D メソッドでは simd_float4x4 形式の座標を CGPoint に変換しています。
isStraight メソッドでは親指以外の指に関して、各関節の位置を extractPosition2D メソッドで取得し、その関節の間の距離から特定の指が伸びているかどうかを判定しています。
isThumbExtended メソッドでは親指が伸びているかどうかを各関節の位置から判定しています。親指は他の指とは構造が異なるため、判定のメソッドも別で設けています。
jointPosition メソッドでは、名前の通り各関節の位置を取得しています。このメソッドから得られた関節の位置をもとに isStraight などの判定を行なっています。

private func extractPosition2D(_ transform: simd_float4x4?) -> CGPoint? {
    guard let transform = transform else { return nil }
    let position = transform.columns.3
    return CGPoint(
        x: CGFloat(position.x),
        y: CGFloat(position.y)
    )
}
    
private func isStraight(hand: Hands, finger: Fingers) -> Bool {
    if finger == .thumb {
        return isThumbExtended(hand: hand)
    }
        
    guard let tipPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .tip)),
    let secondPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .pip)),
    let posWrist = extractPosition2D(jointPosition(hand: hand, finger: .wrist, joint: .tip)) else {
        return false
    }

    let tipToWristDistance = posWrist.distance(to: tipPosition)
    let secondToWristDistance = posWrist.distance(to: secondPosition)
        
    return secondToWristDistance < tipToWristDistance * 0.9
}

private func isThumbExtended(hand: Hands) -> Bool {
    guard let thumbTipPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .tip)),
    let thumbIPPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .pip)),
    let thumbCMCPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .mcp)) else {
        return false
    }

    let distalSegmentLength = thumbIPPosition.distance(to: thumbTipPosition)
    let proximalSegmentLength = thumbCMCPosition.distance(to: thumbIPPosition)
        
    let extensionThreshold = 1.2
    return distalSegmentLength > proximalSegmentLength * extensionThreshold
}
    
private func jointPosition(hand: Hands, finger: Fingers, joint: JointType) -> simd_float4x4? {
    let anchor = hand == .left ? leftHandAnchor : rightHandAnchor
    guard let skeleton = anchor?.handSkeleton else { return nil }

    let jointName: HandSkeleton.JointName
    switch (finger, joint) {
        case (.thumb, .tip): jointName = .thumbTip
        case (.thumb, .pip): jointName = .thumbIntermediateBase
        case (.thumb, .mcp): jointName = .thumbIntermediateTip
        case (.index, .tip): jointName = .indexFingerTip
        case (.index, .pip): jointName = .indexFingerIntermediateBase
        case (.middle, .tip): jointName = .middleFingerTip
        case (.middle, .pip): jointName = .middleFingerIntermediateBase
        case (.ring, .tip): jointName = .ringFingerTip
        case (.ring, .pip): jointName = .ringFingerIntermediateBase
        case (.little, .tip): jointName = .littleFingerTip
        case (.little, .pip): jointName = .littleFingerIntermediateBase
        case (.wrist, .tip): jointName = .wrist
        default: return nil
    }

    return skeleton.joint(jointName).anchorFromJointTransform
}

最後に以下のコードでは CGPoint の extension として distance を設定しています。
このメソッドでは名前の通り CGPoint の間の距離を返します。このメソッドを用いることで2点間の距離の測定が簡単になり、各関節の距離の測定などが楽になります。

extension CGPoint {
    func distance(to point: CGPoint) -> CGFloat {
        return sqrt(pow(x - point.x, 2) + pow(y - point.y, 2))
    }
}

以下に distance メソッドの例を提示します。

let point1 = CGPoint(x: 0, y: 0)
let point2 = CGPoint(x: 3, y: 4)
let distance = point1.distance(to: point2)  // 結果は5.0

3. Viewの実装

次に View の実装を行います。
View の実装は以下の2ステップで行います。

  1. ContentView の編集
  2. ImmersiveView の編集

1. ContentView の編集

まずは ContentView の編集を行います。
コードは以下の通りです。

import SwiftUI
import RealityKit
import RealityKitContent

struct HandTrackingCountContentView: View {
    @StateObject private var viewModel = HandTrackingViewModel.shared
    @State private var showImmersiveSpace = false
    @State private var immersiveSpaceIsShown = false

    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

    var body: some View {
        VStack {
            HStack {
                VStack {
                    Text("Left Hand")
                    Text("\(describeGesture(viewModel.leftHandGesture))")
                        .font(.title)
                        .padding()
                    HStack {
                        ForEach(0..<leftHandFingerCount, id: \.self) { _ in
                            Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
                                model.resizable()
                            } placeholder: {
                                ProgressView()
                            }
                            .frame(width: 50, height: 50)
                            .frame(depth: 50)
                        }
                    }
                }
                .padding()
                
                VStack {
                    Text("Right Hand")
                    Text("\(describeGesture(viewModel.rightHandGesture))")
                        .font(.title)
                        .padding()
                    HStack {
                        ForEach(0..<rightHandFingerCount, id: \.self) { _ in
                            Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
                                model.resizable()
                            } placeholder: {
                                ProgressView()
                            }
                            .frame(width: 50, height: 50)
                            .frame(depth: 50)
                        }
                    }
                }
                .padding()
            }
            
            Text("Total")
            Text("\(viewModel.displayedNumber)")
                .font(.title)
            HStack {
                ForEach(0..<viewModel.displayedNumber, id: \.self) { _ in
                    Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
                        model.resizable()
                    } placeholder: {
                        ProgressView()
                    }
                    .frame(width: 50, height: 50)
                    .frame(depth: 50)
                }
            }
            
            Toggle("Hand Tracking", isOn: $showImmersiveSpace)
                .toggleStyle(.button)
                .padding(.top, 50)
        }
        .padding()
        .onChange(of: showImmersiveSpace) { _, newValue in
            Task {
                if newValue {
                    switch await openImmersiveSpace(id: "HandTracking") {
                    case .opened:
                        immersiveSpaceIsShown = true
                        await viewModel.startHandTracking()
                    case .error, .userCancelled:
                        fallthrough
                    @unknown default:
                        immersiveSpaceIsShown = false
                        showImmersiveSpace = false
                    }
                } else if immersiveSpaceIsShown {
                    await dismissImmersiveSpace()
                    immersiveSpaceIsShown = false
                }
            }
        }
    }
    
    var leftHandFingerCount: Int {
        if case .custom(let count) = viewModel.leftHandGesture {
            return count
        }
        return 0
    }
    
    var rightHandFingerCount: Int {
        if case .custom(let count) = viewModel.rightHandGesture {
            return count
        }
        return 0
    }
    
    func describeGesture(_ gesture: HandGesture) -> String {
        switch gesture {
        case .notTracked:
            return "Not tracked"
        case .closed:
            return "0"
        case .custom(let count):
            return "\(count)"
        }
    }
}

それぞれ詳しくみていきます。
以下では HandTrackingCountContentView に必要な変数の定義を行っています。
具体的には、先程実装した HandTrackingViewModelsharedviewModel としたり、 ハンドトラッキングに必要な ImmersiveSpace の State管理をするための変数定義をしたりしています。

@StateObject private var viewModel = HandTrackingViewModel.shared
@State private var showImmersiveSpace = false
@State private var immersiveSpaceIsShown = false

@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

以下では左手の伸ばしている指の本数に応じて数字とリンゴのモデルを変える実装を行なっています。
具体的には、describeGesture(viewModel.leftHandGesture) の部分で、 leftHandGesture の状態に応じた表示を行い、 ForEach の上限として leftHandFingerCount を指定することで、左手の伸ばしている指の本数だけ Apple という3Dモデルを表示させるようにしています。
なお、右手も同じような実装になっているため解説は割愛します。

VStack {
    Text("Left Hand")
    Text("\(describeGesture(viewModel.leftHandGesture))")
        .font(.title)
        .padding()
    HStack {
        ForEach(0..<leftHandFingerCount, id: \.self) { _ in
            Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
                model.resizable()
            } placeholder: {
                ProgressView()
            }
                .frame(width: 50, height: 50)
                .frame(depth: 50)
        }
    }
}                 

以下では左右の手で伸びている指の本数の合計値とリンゴのモデルを表示させています。
左右の合計値は viewModel.displayedNumber で直接呼び出すことができます。

Text("Total")
Text("\(viewModel.displayedNumber)")
    .font(.title)
HStack {
    ForEach(0..<viewModel.displayedNumber, id: \.self) { _ in
        Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
            model.resizable()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 50, height: 50)
        .frame(depth: 50)
    }
}

以下では、 ImmersiveSpace を開いたり閉じたりするための Toggle ボタンを設置しています。

Toggle("Hand Tracking", isOn: $showImmersiveSpace)
    .toggleStyle(.button)
    .padding(.top, 50)

以下では先述の Toggle ボタンによって showImmersiveSpace が切り替わった際の処理を記述しています。値が true の時は HandTracking という名前の ImmersiveSpace を開き、 viewModel.startHandTracking() を実行することでハンドトラッキングを開始しています。
開く際にエラーが生じた場合、キャンセルされた場合、デフォルトの場合では immersiveSpaceIsShownshowImmersiveSpace の両方を false に指定しています。

.onChange(of: showImmersiveSpace) { _, newValue in
    Task {
        if newValue {
            switch await openImmersiveSpace(id: "HandTracking") {
                case .opened:
                    immersiveSpaceIsShown = true
                    await viewModel.startHandTracking()
                case .error, .userCancelled:
                    fallthrough
                @unknown default:
                    immersiveSpaceIsShown = false
                    showImmersiveSpace = false
            }
        } else if immersiveSpaceIsShown {
            await dismissImmersiveSpace()
            immersiveSpaceIsShown = false
        }
    }
}

以下のコードでは左右の手のジェスチャーに応じて表示する数字を制御したり、ジェスチャーの状態を表すテキストを返したりするメソッドを定義しています。

var leftHandFingerCount: Int {
    if case .custom(let count) = viewModel.leftHandGesture {
        return count
    }
    return 0
}
    
var rightHandFingerCount: Int {
    if case .custom(let count) = viewModel.rightHandGesture {
        return count
    }
    return 0
}
    
func describeGesture(_ gesture: HandGesture) -> String {
    switch gesture {
        case .notTracked:
            return "Not tracked"
        case .closed:
            return "0"
        case .custom(let count):
            return "\(count)"
    }
}

これで ContentView 側の編集は完了です。

2. ImmersiveView の編集

次に ImmersiveView の編集を行います。
コードは以下の通りです。
以下では単純に「Hand Tracking」というテキストのみを表示させるビューを作成しています。 HandTrackin を行うためには ImmersiveSpace で行う必要があるかと思うので、特に重要な処理の実行は行なっていません。

import SwiftUI
import RealityKit
import ARKit

struct HandTrackingCountView: View {
    var body: some View {
        Text("Hand Tracking")
    }
}

4. Appの実装

最後に App の実装を行います。
コードは以下の通りです。
ContentView と ImmersiveSpace のシンプルな作りで、 "HandTracking" という id で openImmersiveSpace を実行することで、 HandTrackingCountView が開くようになっています。

import SwiftUI

@main
struct HandTrackingSampleApp: App {
    var body: some Scene {
        WindowGroup {
            HandTrackingCountContentView()
        }
        
        ImmersiveSpace(id: "HandTracking") {
            HandTrackingCountView()
        }
    }
}

これで関連する実装は完了です。

「Apple」など自分で配置したいモデルを追加して、以上のコードで実行すると、先程紹介した以下の動画のようにトラッキングができているかと思います。

https://youtu.be/VaBKzB6khKQ

また、先述の通り今回作成したプロジェクトを以下で公開しているので、よろしければご覧ください。

https://github.com/Koichi5/vision-os-hand-tracking

まとめ

今回はARKitを用いてハンドトラッキングを実装しました。
ユーザーの手の情報自体は HandTrackingProviderARKitSession で実行するだけで取得できるので、取得だけだと簡単に実装できるかと思います。しかし、それぞれの関節の位置から手のジェスチャーを判定するのは難しかった印象があります。
さらに複雑なジェスチャーが必要な場合は手探りで実装する必要があるかと思います。

冒頭でも述べましたが、ユーザーの動きに合わせてアプリが変化していくことでよりユーザーにとって没入感のある体験を提供できると思うので、この辺りの実装に慣れていきたいところです。

参考

https://developer.apple.com/documentation/arkit/handtrackingprovider/

https://qiita.com/AlohaYos/items/aa7a6b0190d35a275e63

https://1planet.co.jp/tech-blog/applevisionpro-oneplanet-arkit-handtracking-001

Discussion