
Apple Vision Proのハンドトラッキングでスペースシップを操縦しよう
この記事はvisionOS Advent Calendar 2024 10日目の記事です。
アプリの紹介
9月にApple Vision Pro向けのハンドトラッキングを活用した空間ゲーム Space Time Attackをリリースしました!アプリの全体的な紹介や構成などについては下記記事を参照していただければと思いますが、今回はアプリのコアともいえるハンドトラッキング操作について深掘りしてみたいと思います。アップデートで追加したハンドトラッキングモードについても紹介します。
ハンドトラッキングの準備
visionOS 2.0+であれば、SpatialTrackingSessionを使えば簡単にハンドトラッキングを実現できます。Configurationでトラッキングしたいデータ(hand以外にもworldやplane, faceなどがある)を指定して初期化を行いsession.run()を実行すれば、AnchoringComponentを持つEntityからtransformの情報を取得できるようになります。
final class HandsShipControlProviderSystem: System {
let session = SpatialTrackingSession()
init(scene: Scene) {
Task { @MainActor in
let config = SpatialTrackingSession.Configuration(tracking: [.hand])
_ = await session.run(config)
}
}
}
ハンドトラッキングのアンカーポイントは、以下のようにAnchorEntityをAnchoringComponent.Targetを指定して生成し、RealityView.contentに追加します。また、AnchoringComponent.Targetではworldやplane、faceなども指定できますが、今回はハンドトラッキングを行いたいので、AnchoringComponent.Target.hand(_:location:)を指定します。
.handのlocation引数ではAnchoringComponent.Target.HandLocationを指定しますが、タイププロパティとしてaboveHand, indexFingerTip, palm, thumbTip, wristがあり、さらに細かい手の関節を指定したい場合は、AnchoringComponent.Target.HandLocation.HandJointを使うこともできます。
struct HandTrackingComponent: Component {
// ハンドトラッキング位置の定義
enum Location: String {
case leftIndexFingerTip
case leftIndexFingerIntermediateBase
case leftThumbTip
case leftPalm
case rightPalm
}
let location: HandTrackingComponent.Location
}
extension Entity {
static func makeHandTrackingEntities() -> Entity {
let container = Entity()
container.name = "HandTrackingEntitiesContainer"
// AnchorEntityをAnchoringComponent.Targetを指定して生成する
let leftHand = AnchorEntity(.hand(.left, location: .palm))
leftHand.components.set(HandTrackingComponent(location: .leftPalm))
let rightHand = AnchorEntity(.hand(.right, location: .palm))
rightHand.components.set(HandTrackingComponent(location: .rightPalm))
let indexTip = AnchorEntity(.hand(.left, location: .indexFingerTip))
indexTip.components.set(HandTrackingComponent(location: .leftIndexFingerTip))
// HandJointを使ってAnchorEntityを生成でき、細かい関節も指定できる
let joint = AnchoringComponent.Target.HandLocation.joint(for: .indexFingerIntermediateBase)
let indexIntermediateBase = AnchorEntity(.hand(.left, location: joint))
indexIntermediateBase.components.set(HandTrackingComponent(location: .leftIndexFingerIntermediateBase))
let thumbTip = AnchorEntity(.hand(.left, location: .thumbTip))
thumbTip.components.set(HandTrackingComponent(location: .leftThumbTip))
container.addChild(leftHand)
container.addChild(rightHand)
container.addChild(indexTip)
container.addChild(indexIntermediateBase)
container.addChild(thumbTip)
return container
}
}

これでアンカーポイントを指定してハンドトラッキングの準備ができました。上で指定した5つのアンカーポイントに座標軸オブジェクトを追加して実際に手に重ねてみるとこのようになります。ちなみに、写真では手のひらは下向きのため、座標軸オブジェクトの向きも回転しています。

ハンドトラッキングのセットアップについてはこちらのサンプルコードもぜひご参照ください。
ハンドトラッキングでスペースシップの操縦に必要なパラメータを計算する
スペースシップの操縦にはthrottle, pitch, rollのパタメータが必要で、throttleから推進力を、pitch, rollから姿勢を計算して反映することで操縦ができます。これらのパラメータはトラッキングしているAnchorEntityからtransformを取得して計算します。
ハンドトラッキング中に常にこの計算を行いながらパラメータを更新するためには、Systemに準拠したHandsShipControlProviderSystemを用意して、シーン更新時に呼ばれるupdate(context:)の中で計算を行います。
ちなみに、SystemはRealityKitが採用しているECS (Entity Component System)というデータ指向の設定パターンの要素で、クエリでEntityを効率的に検索して、Entityに振る舞いを定義して動作やロジックを実装できます。詳しくはこちらのセッションをご参照ください。
HandsShipControlProviderSystemでは、まずEntityを検索するためのクエリとしてEntityQuery(where: .has(HandTrackingComponent.self))を定義してupdate(context:)で指定することで、先程紹介したmakeHandTrackingEntitiesでHandTrackingComponentを付与して生成したAnchorEntityを抽出できます。
そして、AnchorEntityから4x4のtransform行列を取得して、トラッキング位置と紐付けて辞書に格納しておきます。さらに、ShipControlComponent(コントロールパラメータを保持している)を持つEntity(スペースシップ)を抽出して、先程格納したtransform行列を使ってパラメータを計算して更新します。
final class HandsShipControlProviderSystem: System {
static var dependencies: [SystemDependency] = [.before(ShipControlSystem.self)]
let session = SpatialTrackingSession()
...
func update(context: SceneUpdateContext) {
var transforms: [HandTrackingComponent.Location: simd_float4x4] = [:]
// HandTrackingComponentを持つentities (AnchorEntity)からtransformを取得して保持する
for entity in context.entities(matching: EntityQuery(where: .has(HandTrackingComponent.self)), updatingSystemWhen: .rendering) {
guard let anchorEntity = entity as? AnchorEntity, anchorEntity.isAnchored else { continue }
guard let handTrackingComponent = entity.components[HandTrackingComponent.self] else { continue }
transforms[handTrackingComponent.location] = entity.transformMatrix(relativeTo: nil)
}
// ShipControlComponentを持つentitiesに対して、上で取得したtransformをもとにパラメータを更新
for entity in context.entities(matching: EntityQuery(where: .has(ShipControlComponent.self)), updatingSystemWhen: .rendering) {
updateShipControlParameters(for: entity, context: context, transforms: transforms)
}
}
...
@MainActor
func updateShipControlParameters(for entity: Entity, context: SceneUpdateContext, transforms: [HandTrackingComponent.Location: simd_float4x4]) {
let shipControlParameters = entity.components[ShipControlComponent.self]!.parameters
guard let leftIndexFingerTipTransform = transforms[.leftIndexFingerTip],
let leftIndexFingerIntermediateBaseTransform = transforms[.leftIndexFingerIntermediateBase],
let leftThumbTipTransform = transforms[.leftThumbTip],
let leftPalmTransform = transforms[.leftPalm],
let rightPalmTransform = transforms[.rightPalm] else { return }
// ハンドトラッキングモードごとにthrottle, pitch, rollを計算してパラメータを更新する
switch handTrackingMode {
case .pinchAndTilt:
// 左手の親指と人差し指の先端のtransformからthrottleを計算
let targetThrottle = computeTargetThrottle(leftIndexFingerTipTransform: leftIndexFingerTipTransform, leftThumbTipTransform: leftThumbTipTransform)
shipControlParameters.throttle = interpolateThrottle(current: currentThrottle,
target: targetThrottle,
deltaTime: context.deltaTime)
// 両手のひらのtransformからpitch, rollを計算
let (targetPitch, targetRoll) = computePitchAndRoll(leftPalmTransform: leftPalmTransform, rightPalmTransform: rightPalmTransform)
shipControlParameters.pitch = targetPitch
shipControlParameters.roll = targetRoll
case .steeringWheel:
// 左手の親指の先端と人差し指の関節のtransformからthrottleを計算
let targetThrottle = computeTargetThrottle(leftThumbTipTransform: leftThumbTipTransform, leftIndexFingerIntermediateBaseTransform: leftIndexFingerIntermediateBaseTransform)
shipControlParameters.throttle = interpolateThrottle(current: currentThrottle,
target: targetThrottle,
deltaTime: context.deltaTime)
// 両手のひらのtransformからpitch, rollを計算
let (targetPitch, targetRoll) = computePitchAndRollForSteering(leftPalmTransform: leftPalmTransform, rightPalmTransform: rightPalmTransform)
shipControlParameters.pitch = targetPitch
shipControlParameters.roll = targetRoll
}
}
}
ハンドトラッキングモードと計算ロジック
Space Time Attackでは、2種類のハンドトラッキングモードを用意していて、モードごとに異なる操作感を楽しむことができます。
Pinch & Tilt
シンプルな操作モード
左手の人差し指と親指をピンチでthrottleをコントロール
右手を傾けてpitch, rollをコントロール
Thumbs-up & Steering
飛行機のステアリングを操作するようなモード
左手をthumbs-up, downでthrottleをコントロール
両手でハンドルを握るようにして回すとrollを、前後に伸縮でpitchをコントロール

設定されたモードをもとにハンドトラッキングを行うためには、先程紹介したHandsShipControlProviderSystemのパラメータ更新処理でモードに応じてthrottle, pitch, roll計算を行います。
Pinch & Tiltモードでのパラメータ計算
このモードでは、左手の人差し指と親指をピンチしてthrottleをコントロールするため、leftIndexFingerTipTransform, leftThumbTipTransformのtranslationからそれらの距離を取得して、throttleが取りうる値の範囲である0…1に変換します。
func computeTargetThrottle(leftIndexFingerTipTransform: float4x4, leftThumbTipTransform: float4x4) -> Float {
let indexTipPosition = leftIndexFingerTipTransform.translation
let thumbTipPosition = leftThumbTipTransform.translation
let pinchMagnitude = distance(indexTipPosition, thumbTipPosition)
// pinchMagnitudeをthrottleが取りうる値の範囲である0...1に変換する
return PinchToThrottle.computeThrottle(with: pinchMagnitude)
}
一方で、pitch, rollの計算はやや複雑で、まずleftPalmTransform, rightPalmTransformのtranslationから基準座標系の変換行列midpointを計算して、rightPalmTransformを基準座標系に対する相対的なtransformに変換します。右手の傾きだけでいいのでは?と思われるかもしれませんが、この変換を行わないと、基準座標系がImmersive Spaceに入った時のままで、身体を左右に回転するとrightPalmTransformも影響を受けてpitch, rollの値が大きく変化してしまいます。
rightPalmTransformを変換して得られたhandRelativeToMidpointを使って、手の前方向・親指方向のベクトルを計算します。さらに、それぞれをYZ平面やXY平面に投影し、Z軸やX軸からの角度を計算することでpitch, rollが得られます。
func computePitchAndRoll(leftPalmTransform: float4x4, rightPalmTransform: float4x4) -> (Float, Float) {
let leftPalmPosition = leftPalmTransform.translation
let rightPalmPosition = rightPalmTransform.translation
// 左の手のひらから右の手のひらへの相対ベクトル
let right = normalize(rightPalmPosition - leftPalmPosition)
// ワールドでの上方向のベクトル
let worldUp = SIMD3<Float>(0, 1, 0)
// right, worldUpの外積から得られる後ろ方向のベクトル
let backward = normalize(cross(right, worldUp))
// backward, rightの外積から得られる上方向のベクトル
let upward = normalize(cross(backward, right))
// 基準座標系への変換行列
let midpoint = float4x4(columns: (
SIMD4<Float>(right, 0),
SIMD4<Float>(upward, 0),
SIMD4<Float>(backward, 0),
[0, 0, 0, 1]))
// 変換行列を使ってrightPalmTransformを基準座標系でのtransformに変換する
let handRelativeToMidpoint = midpoint.inverse * rightPalmTransform
// rightPalmTransformでは
// [1, 0, 0]はpalmからwristへ向かうベクトル
// [0, 0, 1]はpalmから親指の反対方向へ向かうベクトル
// ※先程のハンドトラッキングの写真を参照
let palmToWrist: SIMD4<Float> = [-1, 0, 0, 0]
let palmToThumb: SIMD4<Float> = [0, 0, -1, 0]
// 手の前方向のベクトル
let handForward = normalize((handRelativeToMidpoint * palmToWrist).xyz)
// 手の親指方向のベクトル
let handThumb = normalize((handRelativeToMidpoint * palmToThumb).xyz)
// handForwardをYZ平面に投影する
let projectToPitchPlane: SIMD3 = normalize(SIMD3(0, handForward.y, handForward.z))
// Z軸からの角度
let angleFromZ: Float = atan2(projectToPitchPlane.y, projectToPitchPlane.z)
// Z軸の正の方向からの角度を、負の方向からの角度に変換してpitchを計算する
let pitch: Float = -angleFromZ + .pi * sign(angleFromZ)
// handThumbをXY平面に投影する
let projectToRollPlane: SIMD3 = normalize(SIMD3(handThumb.x, handThumb.y, 0))
// X軸からの角度
let angleFromX: Float = atan2(projectToRollPlane.y, projectToRollPlane.x)
// X軸の正の方向からの角度を、負の方向からの角度に変換してrollを計算する
let roll: Float = angleFromX - .pi * sign(angleFromX)
return (pitch, roll)
}
Thumbs-up & Steeringモードでのパラメータ計算
このモードでは、サムズアップでthrottleをコントロールしますが、先程と同じようにleftThumbTipTransform, leftIndexFingerIntermediateBaseTransformのtranslationからそれらの距離を取得して、throttleが取りうる値の範囲である0…1に変換します。
func computeTargetThrottle(leftThumbTipTransform: float4x4, leftIndexFingerIntermediateBaseTransform: float4x4) -> Float {
let thumbTipPosition = leftThumbTipTransform.translation
let indexIntermediateBasePosition = leftIndexFingerIntermediateBaseTransform.translation
let pinchMagnitude = distance(indexIntermediateBasePosition, thumbTipPosition)
// pinchMagnitudeをthrottleが取りうる値の範囲である0...1に変換する
return PinchToThrottle.computeThrottle(with: pinchMagnitude)
}
一方、pitch, rollの計算はシンプルで、pitchは予め定義した腕の伸縮範囲の中間点からの距離を角度に変換して計算し、rollは両手のひらの相対ベクトルのX軸からの角度をもとに計算します。
補足ですが、腕の伸縮範囲はプレイヤーごとにキャリブレーションすると精度が高くなりますが、今回はそこまでの精度を求めず決め打ちで定義しました。また、XZ平面に投影した手のひらの位置と原点の距離をもとにpitchを計算していますが、原点からプレイヤーが動くとズレてしまうのも許容しています。
func computePitchAndRollForSteering(leftPalmTransform: float4x4, rightPalmTransform: float4x4) -> (Float, Float) {
let leftPalmPosition = leftPalmTransform.translation
let rightPalmPosition = rightPalmTransform.translation
// rightPalmTransformをXZ平面に投影する
let projectToPitchPlane = SIMD3(rightPalmPosition.x, 0, rightPalmPosition.z)
// 投影したベクトルの長さを計算する
let length = distance(projectToPitchPlane, .zero)
// 予め定義した腕の伸縮範囲から中間点を求め、その中間点に対する距離にpi/2を掛けてpitchを計算する
let halfRange = (Constants.rightPalmRangeForPitch.upperBound - Constants.rightPalmRangeForPitch.lowerBound) / 2
let rangeMidPoint = Constants.rightPalmRangeForPitch.lowerBound + halfRange
let rawPitch = -(length - rangeMidPoint) / halfRange * Float.pi / 2
// pitchの値を-pi/2 ~ pi/2に収める
let pitch = rawPitch.clamped(in: (-.pi/2)...(.pi/2))
// 左の手のひらから右の手のひらへの相対ベクトル
let rightToLeft = leftPalmPosition - rightPalmPosition
// XY平面に投影する
let projectToRollPlane: SIMD3 = normalize(SIMD3(rightToLeft.x, rightToLeft.y, 0))
// X軸からの角度を計算する
let angleFromX: Float = atan2(projectToRollPlane.y, projectToRollPlane.x)
// X軸の正の方向からの角度を、負の方向からの角度に変換してrollを計算する
let roll: Float = angleFromX - .pi * sign(angleFromX)
return (pitch, roll)
}
extension Comparable {
func clamped(in range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
計算されたパラメータでスペースシップを制御する
ここまでで、ハンドトラッキングでthrottle, pitch, rollのパラメータを計算して更新できましたが、これらのパラメータを使ってスペースシップを制御するためには、以下の図のような流れで複数のSystem, Componentを経由してスペースシップに反映していきます。詳しく知りたい場合は、最後に記載した記事や発表資料をぜひご参照ください。
HandsShipControlProviderSystem: ハンドトラッキング処理からパラメータを計算して、ShipControlComponentの値を更新
ShipControlSystem: ShipControlComponentの値をさらにThrottleComponent, PitchRollComponentに渡す
ShipFlightSystem: ThrottleComponent, PitchRollComponentからパラメータを取得して、飛行時の姿勢や推進力を計算して、スペースシップのEntityに反映させる

さいごに
長文になりましたが、Space Time Attackというハンドトラッキングでスペースシップを操縦する空間ゲームのハンドトラッキング操作について深掘りしてきました。Apple Vision Proのハンドトラッキングの精度は非常に高く、visionOS 2で簡単に使えるので、ハンドトラッキングを活用したいろんなアプリやゲームが作れそうで、今後の開発でもどんどん活用したいと思います!
今回開発したアプリについて詳しく紹介しているこちらの記事や発表資料もぜひご参照ください!