👆
【visionOS/ARKit】手で距離を測るメジャーアプリを作る【追記あり】
Apple Vision ProのハンドトラッキングAPIを用いて、両手で直感的に距離を測るメジャーアプリを作りましょう!
概要
- 両手の人差し指の先端間の距離を測ります。
- メジャーの3Dオブジェクトを表示します。
- メジャーの中心に測定数値を表示します。
- 単位はメートルです。
ソースコード全体
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」を設定
ソースコードの解説
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」というアプリの簡略版です。
追記 2024/10/09
visionOS 2.0でOcclusionComponentの挙動が変わった影響で、適切に動作しなかった不具合を修正しました。
リンク
Discussion