実装メモ:Vision Pro の向いている仰角/伏角(pitch)を求める

Vision Pro 装着時の頭部が、水平に対して何度上下に向いているか(仰/伏角、ピッチ)を知るには、頭部(カメラ)方向のベクトルについて、XZ平面に射影したベクトル長とY軸方向の成分とで atan を計算すれば、その角度(ラジアン)が求まる。

カメラ方向ベクトルの求め方は、SATOSHI さんのサンプルコードを参照。

let worldTracking = WorldTrackingProvider()

fun headPitchRadian() -> CGFloat {
    let matrix = worldTracking.deviceOriginFromAnchorTransform!
    // カメラ前方方向
    let cameraForward = simd_act(matrix.rotation, simd_float3(0, 0, -1))
    let front = SIMD3<Float>(x: cameraForward.x, y: cameraForward.y, z: cameraForward.z)
    // XZ平面に射影したベクトル長
    let horizontalLen = simd_length(SIMD2<Float>(front.x, front.z))
    return CGFloat(atan2(front.y, horizontalLen))
}

ここで、worldTracking.deviceOriginFromAnchorTransform に対して [0, 0, 1] によりZ軸方向に反転させているのは、もともとデバイス正面がZ軸の負方向を向いているため。

カメラ方向ベクトルの算出は以下でもOK。

// columns.2 がデバイスZ軸方向のワールド座標系におけるベクトル
let zAxis = SIMD3<Float>(
    matrix.columns.2.x,
    matrix.columns.2.y,
    matrix.columns.2.z
)
let cameraForward = simd_normalize(-zAxis) // 反転

実装メモ:デバイス座標系における Entity の座標を求める

以下実装のために、デバイス座標系における Entity の座標を求める必要があった。

trackingMode = .continuous とした AnchorEntity(.head) の座標系に変換すれば簡単にできるかと思ったが、デバイス位置にアンカーがうまく追従されず、原因は分からなかったが先に進めたかったので、座標変換して求めることにした。

デバイス→ワールド座標系の変換行列 の逆行列をとれば、ワールド→ワールド座標系の変換行列となり、ワールド座標系における任意の座標をデバイス座標系に変換することができる。

let worldTracking = WorldTrackingProvider()

func positionInDeviceSpace(of entity: Entity) -> SIMD3<Float> {
    guard let deviceAnchor = worldTracking.queryDeviceAnchor(atTimestamp: CACurrentMediaTime()) else { return .zero }
    let worldFromDevice = deviceAnchor.originFromAnchorTransform
    let deviceFromWorld = worldFromDevice.inverse
    let worldFromEntity = entity.transformMatrix(relativeTo: nil)
    let deviceFromEntity = deviceFromWorld * worldFromEntity
    return SIMD3<Float>(
        deviceFromEntity.columns.3.x,
        deviceFromEntity.columns.3.y,
        deviceFromEntity.columns.3.z
    )
}

実装メモ:Vision Pro のハンドトラッキング検出可能な角度を実験してみた

HandTrackingProvider を用いたトラッキングで、どこまで広域に検出可能なのか実験してみた。

デバイスに対して水平(X軸)方向を0度とし、正面(Z軸)方向を90度とした時、試してみると-10度強くらいまでは継続的・安定的にトラッキングできた。デバイスに対してやや後ろまでの検出はあてにして良さそうだ。

数値上は-20度近くまで到達できたが、ここまでくるとトラッキングが一時的に失われることもあった。


検証環境:Vision Pro (2024) / visionOS 26.0.1

実装メモ:ParticleEmitterComponent でパーティクルを Entity 自身に吸収させる

パーティクル演出は基本的に放出されるようなイメージがあるが、visionOS の ParticleEmitterComponent では内向きに集約させることもできるのでメモ。

mainEmitter.attractionCenter に吸収先の座標を指定すればよく、emitterShapeSize を Entity のサイズよりも大きく指定した上で、エミッターを持つ Entity 中心座標を指定すれば、自分自身にパーティクルを吸収させることができる。

var p = entity.components[ParticleEmitterComponent.self]!
p.emitterShape = .sphere
p.emitterShapeSize = .init(x: 0.1, y: 0.1, z: 0.1)
p.fieldSimulationSpace = .local
p.mainEmitter.attractionCenter = entity.position(relativeTo: entity)

entity.components[ParticleEmitterComponent.self] = p

検証環境:visionOS 26.0.1

実装メモ:visionOS でパーティクルのプロパティをランタイムで変更する

Reality Composer Pro で定義したパーティクル(前回記事参照)を、Entity に対し ParticleEmitterComponent で設定したあと、その属性をアプリ動作中に動的に変更することができる。

単純に、ParticleEmitterComponent を取り出して値を変更し、再度入れ直せば良い。

struct ImmersiveView: View {
    @State var model: Entity?
    @ObservedObject var someTracking: SomeTracking

    var body: some View {
        RealityView { content in
            model = ModelEntity(...
            await attachParticleEffect(to: model!) // https://p0dee.com/blog/2025/11/03/define-particle-on-reality-composer-pro-and-attach-to-entity/
        } update: { content in
            guard let model else { return }
            model.position = someTracking.location
            updateParticleEmitterProperties(for: model, value: someTracking.strength)
        }
    }

    func updateParticleEmitterProperties(for model: Entity, value: Float) {
        guard var particleEmitterComp = centerMarker.components[ParticleEmitterComponent.self] else {
            return
        }
        let birthRate = Float(value * 100)
        particleEmitterComp.mainEmitter.birthRate = birthRate
        model.components[ParticleEmitterComponent.self] = particleEmitterComp
    }
}

他にも、RealityKit.System を使って、シーンの毎更新でパーティクルを自動的に更新する方法もありそう(出来た)。この手法は、例えば Apple のサンプルだと惑星や飛行機などの物体を周回軌道に乗せて動かし続ける、といった表現に用いられている。以下のコードだと、ParticleEmitterComponent を持ったすべての Entity を更新対象としいる。雑なので、Entity Component System に乗っ取り、カスタム Component を定義して、属性変更に必要な値もこれを経由して受け渡す設計にすると良いかもしれない。

// https://developer.apple.com/videos/play/wwdc2023/10081/
public class ParticleTransitionSystem: RealityKit.System {
    public required init(scene: RealityKit.Scene) { }
    
    private static let query = EntityQuery(where: .has(ParticleEmitterComponent.self))

    public func update(context: SceneUpdateContext) {
        let entities = context.scene.performQuery(Self.query)
        for entity in entities {
            updateParticles(entity: entity)
        }
    }
    
    public func updateParticles(entity: Entity) {
        guard var particleEmitterComp = entity.components[ParticleEmitterComponent.self] else {
            return
        }
        // スケールに応じてパーティクルの放出量を変化させる
        let scale = entity.scale(relativeTo: nil).x
        let birthRate = Float(scale * 100)
        particleEmitterComp.mainEmitter.birthRate = birthRate
        entity.components[ParticleEmitterComponent.self] = particleEmitterComp
    }
}

実装メモ:Reality Composer Pro で定義したパーティクルを Reality View のエンティティにアタッチする

visionOS/App プロジェクトを作成したら初期状態で存在する Packages/RealityKitContent/Package を Reality Composer Pro で開き、以下のブログに従ってパーティクルを追加する。

How to make a fire effect using particles and Reality Composer Pro

これを実装で抽出し、任意のエンティティに追加する。

func attachParticleEffect(to entity: Entity) async throws {
    let particlesRoot = try await Entity(
        named: "ParticleEffect",
        in: realityKitContentBundle // already defined in Packages/RealityKitContent/Sources/RealityKitContent/RealityKitContent.swift
    )
    guard
        let emitterSourceEntity = particlesRoot.findEntity(named: "ParticleEmitter"),
        let emitterComponent = emitterSourceEntity.components[ParticleEmitterComponent.self]
    else {
        assertionFailure("Emitter entity or ParticleEmitterComponent not found")
        return
    }
    entity.components.set(emitterComponent)
}

検証環境 Xcode 26.0.1 (17A400)

FM+RAG後日談:埋め込みベクトル化の精度改善

先日、Foundation Models で RAG を試みる内容を登壇したのだが、その時のスライドに添付したソースコードに誤りがあったので、以下ブログ記事に記載していたソースコードを修正した。

もともとはベクトル化対象のテキストを、トークン分割しつつ startIndex から endIndex まで手動で動かしながら畳み込みしていたものを、シンプルに enumerateTokenVectors(in:using:)  を使うようにしたら、↑記事で記載しているイマイチ精度が出ない問題を改善することができた。

以前の実装だと、何らかの条件で文字列最後までループが到達しないことが発生していたようだ。文頭の構文しかヒットしないという現象も、この原因を考えれば納得できる。


そもそも、ここで紹介している NLContextualEmbedding + mean pooling + L2 normalization で埋め込みベクトル化し、コサイン類似度を求める手法は、すでに以下のQiita記事で同じことが解説されていた。今後実装される方はこっちを参考にした方が幸せかもしれない。(もっと早く見つけたかった、、)

iOSに組み込まれたBERTでテキスト埋め込み・ベクトル検索をオンデバイス実行する #Mac – Qiita

SwiftでRAG実装 Part 2:クエリに類似するドキュメント検索の試み

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化 の続き。

前回はドキュメントデータから埋め込み生成。今回はそこから検索クエリに類似するドキュメントを抽出してみる。先に結論を言うと、結果はいまひとつなので調整が必要そう。

今回実装する計算ロジックは以下(現状の筆者の理解であることに注意)。

  1. ドキュメントからそれぞれ埋め込み生成し、ベクトル化する
  2. ドキュメントごとのベクトル表現(D次元)を、ドキュメントの数(M個)分並べて MxD 行列を生成する(ドキュメント行列)
  3. クエリ文字列をベクトル化する。1. と同じ手法で生成しベクトル次元は一致(D次元)。(クエリベクトル)
  4. ドキュメント行列・クエリベクトル(内積)を計算する (MxD・Dx1 = Mx1)
    • つまりドキュメントごとのベクトルとクエリベクトルとの内積が結果として求まる。内積ベクトルのノルム(コサイン類似度)が1に近いほど類似度高く、0、-1に近づくほど類似度が低いと判定できる
  5. 4. の結果で得られた類似度の配列(要素数M)を降順にソート、上から任意の件数を上位ヒットとする
var docTensors: [MLTensor] = ...

// docTensors に含むドキュメントごとのクエリとの類似度を検索し、上位任意件数(maxCount)をヒット結果として返す
// (返すのは該当文書テンソルの docTensors 配列内におけるインデックス番号)
func search(query: String, maxCount: Int) async -> [Int] {
    // ドキュメントを集積した MxD 行列
    let flatteneds = tensors.map { $0.flattened() }
    let docsMat = MLTensor(stacking: flatteneds)
    // クエリベクトル Mx1 (encode関数は前回記事参照)
    guard let queryVec = try? embedder.encode(text: query, asColumn: true /*列ベクトルで出力*/) else { return [] }
    
    // ドキュメント行列・クエリベクトル (MxD・Dx1 = Mx1)
    let mulResult = docsMat.matmul(queryTensor)
    // 類似度スコア配列に変換
    let calcScores = await mulResult.shapedArray(of: Float.self).scalars
    // ソートして、トップの結果をインデックス番号として抽出
    let map = zip(Array(0..<docTensors.count), calcScores)
    let sorted = map.sorted { $0.1 > $1.1 }.prefix(maxCount)
    return sorted.map { $0.0 }
}

これでサンプルの日記データを検索したところ、肌感としてあまり精度高くなかった。

ただし、クエリの何かしらの特徴は反映していそうだった。たとえば「I watched a movie」のクエリで検索すると、映画を見た日記エントリはヒットしないが、上位ヒットは軒並み「I (動詞過去形)」のパターンで始まっていたり、更に分かりやすい例を挙げると、次のように「at a cafe」クエリに対し「At a/the」で始まるエントリが抽出されたりした。まったくの出鱈目ではなさそうだが、肝心の文章的なコンテキストは明らかに落ちているように見える。

Screenshot

追記(2025/10/01):後日性能改善できたので後々投稿するが、取り急ぎこちらの資料にソースコードを添付した。(enumerateTokenVectors を使うようにしただけ)

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化

Foundation Models のツール呼び出しに使う RAG に Spotlight を利用する試みが絶たれたので、自前で RAG 実装することにした。(本記事の作業は↓のワークショップで取り組んだもの)

経緯:参加メモ:新しいFoundation Modelフレームワークのアプリへの導入(ワークショップ)@Apple Japan

このブログでは繰り返し何度も貼っているが、Apple 武石さんのこのポストを頼みの綱に頑張る。もはやバイブル。


といってもちんぷんかんぷんなので ChatGPT に聞いてみた。

NLContextualEmbedding を使ってRAGを構築するにはどう言った実装が必要になりますか?

NLContextualEmbedding を使った “自前RAG” の実装は、だいたい次の4層で考えると設計しやすいです。

  1. 前処理(分割・メタデータ付与)
  2. 埋め込み生成(NLContextualEmbedding)
  3. ベクタ格納と近傍検索(SQLite + BLOB / CoreData / なんでも)
  4. 検索→再ランキング→プロンプト組み立て→生成(Foundation Models)

いったん前処理は省いて、まずは短い文字列を元にした埋め込み生成(コンテキストベクトル化)を試みた。ChatGPT の実装をもとに動くよう修正し、最終的にそれっぽくベクトルを得ることができた。(コメントは筆者理解の補足なので間違いあるかも)(2025/10/5 実装修正)

import Accelerate
import NaturalLanguage

let embedding: NLContextualEmbedding

/// 文字列をコンテキストベクトル化(平均プーリング+L2正規化)
func encode(text: String) throws -> [Float] {
    let result = try embedding.embeddingResult(for: text, language: overrideLanguage)

    let dim = embedding.dimension
    var sum = [Float](repeating: 0, count: dim)
    var count = 0 // トークン数

    result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vec_double, range in
        var vec_float = [Float](repeating: 0, count: dim)
        // double → float 変換
        vDSP_vdpsp(vec_double, 1, &vec_float, 1, vDSP_Length(dim))
        // vec_float を sum に足し合わせ
        vDSP_vadd(sum, 1, vec_float, 1, &sum, 1, vDSP_Length(dim))
        // トークン数をインクリメント
        count += 1
        return true
    }

    guard count > 0 else { return [Float](repeating: 0, count: dim) }

    // 平均プーリング(トークンベクトルの総和をトークン数で平均して畳み込み)
    var inv_n = 1.0 / Float(count)
    vDSP_vsmul(sum, 1, &inv_n, &sum, 1, vDSP_Length(dim))

    return l2Normalize(sum) // L2 normalization
}

// L2 正規化(ベクトル全体を二乗和平方根で割って正規化)
private func l2Normalize(_ v: [Float]) -> [Float] {
    var vec = v
    var norm: Float = 0
    vDSP_svesq(vec, 1, &norm, vDSP_Length(vec.count))
    norm = sqrtf(norm) + 1e-12
    vDSP_vsdiv(vec, 1, &norm, &vec, 1, vDSP_Length(vec.count))
    return vec
}
print(encode(text: "Hello, world.")

[-0.028537132, -0.014218736, -0.033890422, -0.024530113, 0.009770119, -0.01361734, 0.0034657633, 0.029605899, 0.013323085, -0.005046577, ..., 0.018509272, -0.026693422, -0.6423329, -0.03437927, 0.005926335, -0.022124525, 0.03561643, -0.056179043, 0.025543895, -0.00908023, 0.0050482955, 0.028503625]

ちなみに、今回 “Hello, world.” は Hello,, , world. の3トークンに分割された。


埋め込み生成の処理をおさらいすると

  1. 文字列をもとにベクトル埋め込みを生成(NaturalLanguage.NLContextualEmbedding.embeddingResult(for:language:)
  2. 文字列をトークン(サブテキスト単位)に分割
  3. トークンごとに、トークンベクトルを抽出(NaturalLanguage.NLContextualEmbeddingResult.tokenVector(at:)
  4. すべてのトークンベクトルの平均を計算 → コンテクストベクトル
  5. コンテクストベクトルを二乗和平方根で割って正規化

これ書きながら、2-3 のステップで頑張ってループ回しているところは enumerateTokenVectors(in:using:) 使ったほう良いかも、と思った。

参考:
埋め込み層 (Embedding Layer) [自然言語処理の文脈で] | CVMLエキスパートガイド
平均プーリング(Average Pooling) | CVMLエキスパートガイド
[iOS 17] 多言語BERT埋め込みモデルのサポート


Accelerate framework に馴染みがないので、ここで使われている関数を調べてみた。

  • vDSP_vdpsp: 倍精度のベクトルを単精度のベクトルに変換
  • vDSP_vadd: ベクトル同士の和 (stride 指定可能)
    • stride: 足し合わせ時の要素飛び石数 通常は1だが、オーディオバッファからLRチャンネルを分離して取得する時(stride=2)や、イメージバッファからRGBチャンネルを分離して取得する(stride=3)時に指定
  • vDSP_vsmul: ベクトル * スカラ値 の積算 (stride 指定可能)
  • vDSP_vsdiv: ベクトル / スカラ値 の除算 (stride 指定可能)
  • vDSP_svesq: ベクトルの二乗和 sum(a^2) 、結果はスカラ値

SwiftUI:.searchable でキーボードのエンターが押された時の処理

キーボードでエンターキーが押された時の処理実装においては .onSubmit { ... } modifier を使いイベント検知できるが、.searchable modifier による検索フィールドに対しては検索キーのタップイベントが捕捉できなかった。

.onSubmit には、実は隠れたパラメタ trigger: SubmitTriggers がある。デフォルトで .text が指定されており、入力補完で省略されると気がつけない。

onSubmit(of:_:) | Apple Developer Documentation

以下のように、trigger: .search と指定することで解決できた。

CustomScreen()
   .searchable(text: $text)
// .onSubmit {
//       ❌ 呼ばれない           
// }
   .onSubmit(of: .search, {
      // 👍 呼ばれる
   }