👆

【visionOS/ARKit】手で距離を測るメジャーアプリを作る【追記あり】

2024/05/18に公開

Apple Vision ProのハンドトラッキングAPIを用いて、両手で直感的に距離を測るメジャーアプリを作りましょう!

Example

概要

  • 両手の人差し指の先端間の距離を測ります。
  • メジャーの3Dオブジェクトを表示します。
  • メジャーの中心に測定数値を表示します。
  • 単位はメートルです。

Demo video

ソースコード全体

import SwiftUI
import RealityKit
import ARKit

@main
struct MyApp: App {
    @StateObject private var model = AppModel()
    var body: some SwiftUI.Scene {
        ImmersiveSpace {
            RealityView { content, attachments in
                content.add(model.myEntities.root)
                model.myEntities.add(attachments.entity(for: "resultBoard")!)
            } attachments: {
                Attachment(id: "resultBoard") {
                    Text(model.resultString)
                        .monospacedDigit()
                        .padding()
                        .glassBackgroundEffect()
                        .offset(y: -80)
                }
            }
            .task { await model.runSession() }
            .task { await model.processAnchorUpdates() }
        }
    }
}

@MainActor
class AppModel: ObservableObject {
    private var arKitSession = ARKitSession()
    private var handTrackingProvider = HandTrackingProvider()
    @Published var resultString: String = ""
    let myEntities = MyEntities()
    
    func runSession() async {
        try! await arKitSession.run([handTrackingProvider])
    }
    
    func processAnchorUpdates() async {
        for await update in handTrackingProvider.anchorUpdates {
            let handAnchor = update.anchor
            
            guard handAnchor.isTracked,
                  let joint = handAnchor.handSkeleton?.joint(.indexFingerTip),
                  joint.isTracked else {
                continue
            }
            
            let originFromWrist = handAnchor.originFromAnchorTransform
            
            let wristFromIndex = joint.anchorFromJointTransform
            let originFromIndex = originFromWrist * wristFromIndex
            
            let fingerTipEntity = myEntities.fingerTips[handAnchor.chirality]
            fingerTipEntity?.setTransformMatrix(originFromIndex, relativeTo: nil)
            
            myEntities.update()
            resultString = myEntities.getResultString()
        }
    }
}

@MainActor
class MyEntities {
    let root = Entity()
    let fingerTips: [HandAnchor.Chirality: Entity]
    let line = Entity()
    var resultBoard: Entity?
    
    init() {
        fingerTips = [
            .left: ModelEntity(mesh: .generateSphere(radius: 0.01), materials: [SimpleMaterial()]),
            .right: ModelEntity(mesh: .generateSphere(radius: 0.01), materials: [SimpleMaterial()])
        ]
        fingerTips.values.forEach { root.addChild($0) }
        
        line.components.set(OpacityComponent(opacity: 0.75))
        
        root.addChild(line)
    }
    
    func add(_ resultBoardEntity: Entity) {
        resultBoard = resultBoardEntity
        root.addChild(resultBoardEntity)
    }
    
    func update() {
        let centerPosition = (fingerTips[.left]!.position + fingerTips[.right]!.position) / 2
        let length = distance(fingerTips[.left]!.position, fingerTips[.right]!.position)
        
        line.position = centerPosition
        line.components.set(ModelComponent(mesh: .generateBox(width: 0.01,
                                                              height: 0.01,
                                                              depth: length,
                                                              cornerRadius: 0.005),
                                           materials: [SimpleMaterial()]))
        line.look(at: fingerTips[.left]!.position, from: centerPosition, relativeTo: nil)
        
        resultBoard?.setPosition(centerPosition, relativeTo: nil)
    }
    
    func getResultString() -> String {
        let formatter = MeasurementFormatter()
        formatter.unitOptions = .providedUnit
        formatter.numberFormatter.minimumFractionDigits = 2
        formatter.numberFormatter.maximumFractionDigits = 2
        let length = distance(fingerTips[.left]!.position, fingerTips[.right]!.position)
        return formatter.string(from: .init(value: .init(length), unit: UnitLength.meters))
    }
}

ソースコードはこれだけです。コピペしてください。

コードコピペ以外に必要な作業

  • Info.plistで「NSHandsTrackingUsageDescription」に任意のテキストを設定
  • Info.plistで「Preferred Default Scene Session Role」に「Immersive Space」を設定

Info.plist Screenshot

ソースコードの解説

SwiftUIやRealityKit、ARKitの基本的な知識の説明は省略します。

全体の構成

@main
struct MyApp: App { ... }

class AppModel: ObservableObject { ... }

class MyEntities { ... }

「SwittUI関連」と「アプリ全体のモデル」、「RealityKitのEntity関連」の大まかに3つに分けて実装しています。

ハンドトラッキング部分

class AppModel: ObservableObject {
    private var arKitSession = ARKitSession()
    private var handTrackingProvider = HandTrackingProvider()
    @Published var resultString: String = ""
    let myEntities = MyEntities()
    
    func runSession() async {
        try! await arKitSession.run([handTrackingProvider])
    }
    
    func processAnchorUpdates() async {
        for await update in handTrackingProvider.anchorUpdates {
            let handAnchor = update.anchor
            
            guard handAnchor.isTracked,
                  let joint = handAnchor.handSkeleton?.joint(.indexFingerTip),
                  joint.isTracked else {
                continue
            }
            
            let originFromWrist = handAnchor.originFromAnchorTransform
            
            let wristFromIndex = joint.anchorFromJointTransform
            let originFromIndex = originFromWrist * wristFromIndex
            
            let fingerTipEntity = myEntities.fingerTips[handAnchor.chirality]
            fingerTipEntity?.setTransformMatrix(originFromIndex, relativeTo: nil)
            
            myEntities.update()
            resultString = myEntities.getResultString()
        }
    }
}

HandTrackingProviderのanchorUpdatesから最新の手のジョイントデータを受け取り、人差し指の先端のジョイントの位置をEntityに反映させます。そして、その位置を元に他のEntityやテキストもアップデートします。

テキストはSwiftUIのViewで表示

RealityView { content, attachments in
    ...
    model.myEntities.add(attachments.entity(for: "resultBoard")!)
} attachments: {
    Attachment(id: "resultBoard") {
        Text(model.resultString)
            .monospacedDigit()
            .padding()
            .glassBackgroundEffect()
            .offset(y: -80)
    }
}

このアプリは基本的にRealityKitのEntityで見た目を表現します。しかし、RealityKitの動的なテキスト表示は貧弱なので、テキストに関してはSwiftUIのViewを採用しました。

テキストを微調整

Text(model.resultString)
    .monospacedDigit()
func getResultString() -> String {
    let formatter = MeasurementFormatter()
    formatter.unitOptions = .providedUnit
    formatter.numberFormatter.minimumFractionDigits = 2
    formatter.numberFormatter.maximumFractionDigits = 2
    let length = distance(fingerTips[.left]!.position, fingerTips[.right]!.position)
    return formatter.string(from: .init(value: .init(length), unit: UnitLength.meters))
}

測定結果の数値をそのまま表示するとユーザー体験がとても悪くなります。数値が変わるタイミングで文字全体がズレたり、View全体のサイズが変わったりして見辛くなってしまいます。

そのため、monospacedDigitで等幅フォントにしたり、MeasurementFormatterで小数点以下の桁数を固定したりしてテキストを見易くしました。

注意: Apple Vision Pro実機が必要

ARKitのハンドトラッキングを試すにはApple Vision Pro実機が必要です。シミュレーターでは全く動きません。

元ネタ

今回紹介したのは「HandsRuler」というアプリの簡略版です。

https://apps.apple.com/app/id6475769879

https://www.youtube.com/watch?v=_kAL5OXHVvQ

追記 2024/10/09

visionOS 2.0でOcclusionComponentの挙動が変わった影響で、適切に動作しなかった不具合を修正しました。

リンク

https://github.com/FlipByBlink/HandsRuler

https://qiita.com/mjnfhbuvwebwfiejcnw/items/47d279c812ae9ee3b87f

https://developer.apple.com/wwdc23/10082

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




https://qiita.com/mjnfhbuvwebwfiejcnw/items/90d6e1b312d36cf085c4

https://qiita.com/mjnfhbuvwebwfiejcnw/items/96b64d2474b6886ddd88

https://zenn.dev/huiygfutfgvjknj/articles/134014540063e9

Discussion