e.blog

主にUnity/UE周りのことについてまとめていきます

llama.cppをUnityで扱う



去年(2023年)の3月頃にChatGPTのAPIが公開されてから、AI熱が高まり最近では様々な生成AIが毎日のようにニュースになっています。

MESONでもAIには積極的に取り組んでいて、自分もとてもAIに興味があります。

生成AIでは特に大規模言語モデル(LLM)に興味があって、人生の中で一番ワクワクしているときかもしれません。

このLLM、うまく使えばかなり色々なことが実現できます。特に、ローカルで動くLLMが当たり前になってくると本当にSFの世界のような体験が作れるのでは、と期待しています。

そんなわけなので、ローカルで、特にスマホでLLMを動かすことにとても興味があり、かつ自分はUnityエンジニアなのでLLMをUnityアプリに組み込みたいなと考えていました。

そしてllama.cppという素晴らしいプロジェクトがあります。これは、Meta社が公開しているLlamaというオープンソースのLLMをC++で実装し、様々な環境で動かせるようにしてくれているリポジトリです。

その中でllamacpp.swiftという、iOS向けのプロジェクトがサンプルにあり、iOS実機で試すことができるようになっています。

幸いなことにこれはSwiftパッケージの形で提供されているため、これをビルドしてUnityに組み込む、というのが今回の記事の主題です。

実際に動かしてみたのが以下の動画です。

この組み込みを行う上で少しハマったのでそれを備忘録として残しておきます。


今回実装したものはGitHubに上げてあるので全体を見たい人は参考にしてください。

▼ Unity

github.com

Xcode(llamacpp-wrapper)

github.com

モデルは小さなものでも1GB近くあったりするのでリポジトリには含まれていません。そのため、ご自身でダウンロードして Assets/StreamingAssets/models 内に配置してください。

今回利用したモデルはこちらのモデル(tinyllama-1.1b-1t-openorca.Q4_0.gguf)です。


Unityに組み込む準備

Unityに組み込むにあたり、Swiftパッケージをそのまま持って行くことはできません。また、開発の効率を考えるとUnity Editor上でも動かせる必要があります。ということで、macOS向けとiOS向けにパッケージをビルドし、双方で扱えるようにすることを目標とします。

iOS / macOS向けにビルドする

Swiftパッケージを両プラットフォーム向けにビルドするところから始めましょう。といっても、ビルドに関する記事は前回書いたので、ビルドの仕方そのものは前回の記事を参考にしてください。ここではllama.cppのビルドのみに焦点を絞って書きます。

edom18.hateblo.jp

llama.cppをラップするSwiftパッケージを作成する

llama.cpp自体はSwiftパッケージとして利用できる形になっていますが、本体はC++で実装されています。そのため、llama.cppのパッケージをそのままビルドしてもUnity側で扱える形になっていません。そこで、llama.cppをラップするパッケージを作成し、そのパッケージの依存先としてllama.cppを設定する、という形で実装を行います。

つまりこのパッケージの目的はllama.cppの機能をC#から利用できるようにインターフェースの役割を担います。

前回の記事でも、自作のSwiftパッケージを外部のパッケージに依存させる方法を書いているので詳細はそちらを参照ください。

まずはSwiftパッケージを新規作成(初期化)し、llama.cppを依存関係に追加します。

Swiftパッケージの作成は以下のようにコマンドを実行してください。

$ mkdir llama-wrapper
$ cd llama-wrapper
$ swift package init --type library --name llama-wrapper

上記コマンドを実行するとパッケージに必要なファイルなどが自動生成されます。その中でパッケージの情報を示す Package.swift があるので、必要な設定をしていきます。

具体的には以下を追加・修正します。

  1. dependencies にllama.cppを追加する
  2. GitHub上の名前と一致しないので名前の指定
  3. ライブラリの type.dynamic にして共有ライブラリとする
  4. 対応Platformの指定を追加

最終的に以下のようになります。(必要な部分だけ抜粋)

let package = Package(
    name: "llamacpp-wrapper",
    platforms: [
        .macOS(.v13),
        .iOS(.v16),
        .watchOS(.v4),
        .tvOS(.v14)
    ],
    products: [
        .library(
            name: "llamacpp-wrapper",
            type: .dynamic,
            targets: ["llamacpp-wrapper"]),
    ],
    dependencies: [
        .package(url: "https://github.com/ggerganov/llama.cpp.git", branch: "master"),
    ],
    targets: [
        .target(
            name: "llamacpp-wrapper",
            dependencies: [
                .product(
                    name: "llama",
                    package: "llama.cpp")
            ]),
// 後略
}

Platformにはビルドの際に、対応バージョンなどによってエラーが発生するのでその下限に適合するように指定しています。

まずはビルドしてみる

現時点でビルドが通る状態になっているはずです。特に機能は実装していませんが、以下のコマンドを実行してそれぞれのプラットフォーム向けにビルドできるか確認しておきましょう。

macOS向け

$ swift build -c release --arch arm64 --arch x86_64

macOS向けのビルドは自動的に .build フォルダにビルドされるので、もしFinderなどで見ている場合に不可視ファイルの可能性があるのでターミナルなどから開いてください。

iOS向け

$ xcodebuild -scheme llama-wrapper -configuration Release -sdk iphoneos -destination generic/platform=iOS -derivedDataPath ./Build/Framework build

iOSの場合はビルド先フォルダを指定しているのでそのフォルダを開きます。

※ 前回のビルドの解説のところでも書いたのですが、生成直後のものをビルドしてもTestターゲット向けのビルドでコケるので、今回はひとまずコメントアウトして回避しています。

//        .testTarget(
//            name: "llamacpp-wrapperTests",
//            dependencies: ["llamacpp-wrapper"]),

これでパッケージ作成の準備が整いました。以下から実際に実装をしていきます。

ラッパーを実装する

前段まででUnityに組み込む準備ができました。続いてllama.cppの機能をC#から呼び出すための処理などを追加していきます。

llama.cppの実装を呼び出す機能の実装

llamacpp.swift に含まれている実装をそのまま移植します。具体的には LibLlama.swift をコピーします。この実装は名前から推測できる通り、C++側の実装を呼び出し実際に推論などを行う実装が含まれています。

以下のように追加しました。

今回新規で追加するのは、この LibLlama.swift に実装されている LlamaContext クラスの処理をC#から呼び出せるようにするものです。

今回の実装は、 LibLlama.swift と同様にllama.cppに含まれていた LlamaState.swift の実装を参考にしました。

まずは今回実装したコード全文を載せます。その後、個別に解説します。

import llama
import Foundation

public typealias completion_callback = @convention(c) (UnsafeMutablePointer<CChar>) -> Void

public class LlamaWrapper {
    let NS_PER_S = 1_000_000_000.0
    var llamaContext: LlamaContext?
    
    var message: String = ""
    
    init() {
        
    }
    
    init(llamaContext: LlamaContext) {
        self.llamaContext = llamaContext
    }
    
    public func complete(text: String, completion: completion_callback) async -> Void {
        
        self.message = ""
        
        let t_start = DispatchTime.now().uptimeNanoseconds
        await self.llamaContext?.completion_init(text: text)
        let t_heat_end = DispatchTime.now().uptimeNanoseconds
        let t_heat = Double(t_heat_end - t_start) / NS_PER_S
        
        self.message += "\(text)"
        
        guard let llamaContext = self.llamaContext else {
            let faileMessage = strdup("Failed to create text.")
            completion(faileMessage!)
            return
        }
        
        while await llamaContext.n_cur < llamaContext.n_len {
            let result = await llamaContext.completion_loop()
            self.message += "\(result)"
            
            print(result)
        }
        
        let t_end = DispatchTime.now().uptimeNanoseconds
        let t_generation = Double(t_end - t_heat_end) / NS_PER_S
        let tokens_per_second = Double(await llamaContext.n_len) / t_generation
        
        await llamaContext.clear()
        self.message += """
        \n
        Done
        Heat up took \(t_heat)s
        Generated \(tokens_per_second) t/s\n
        """
        
        let messagePtr = strdup(self.message)
        
        completion(messagePtr!)
    }
}

@_cdecl("create_instance")
public func create_instance(_ pathPtr: UnsafePointer<CChar>) -> UnsafeMutableRawPointer {
    let path = String(cString: pathPtr)
    
    do {
        let llamaContext: LlamaContext = try LlamaContext.create_context(path: path)
        let wrapper: LlamaWrapper = LlamaWrapper(llamaContext: llamaContext)
        return Unmanaged.passRetained(wrapper).toOpaque()
    }
    catch {
        let wrapper: LlamaWrapper = LlamaWrapper()
        return Unmanaged.passRetained(wrapper).toOpaque()
    }
}

@_cdecl("llama_complete")
public func llama_complete(_ pointer: UnsafeMutableRawPointer, _ textPtr: UnsafePointer<CChar>, _ completion: completion_callback) -> Void {
    let llamaWrapper: LlamaWrapper = Unmanaged<LlamaWrapper>.fromOpaque(pointer).takeUnretainedValue()
    let text = String(cString: textPtr)
    
    Task {
        await llamaWrapper.complete(text: text, completion: completion)
    }
}

ラッパークラスを実装

まずはラッパークラス( LlamaWrapper )を見ていきましょう。

ここの実装がまさに LlamaState.swift の実装を参考にしたものです。今回はあまり複雑なことはせず、C#から文字列を受け取り、それをもとにLLMで推論を行うだけのものになっています。

実際の推論については LlamaContext クラスで行うため、それを呼び出すラッパーとして実装しています。そのため、コンストラクタで LlamaContextインスタンスを受け取って利用する形としています。

init(llamaContext: LlamaContext) {
    self.llamaContext = llamaContext
}

生成過程については後述します。

続いて実際に推論する処理である complete メソッドを見ていきます。ここがまさに LlamaState.swift からの引用部分です。

public func complete(text: String, completion: completion_callback) async -> Void {
    
    self.message = ""
    
    let t_start = DispatchTime.now().uptimeNanoseconds
    await self.llamaContext?.completion_init(text: text)
    let t_heat_end = DispatchTime.now().uptimeNanoseconds
    let t_heat = Double(t_heat_end - t_start) / NS_PER_S
    
    self.message += "\(text)"
    
    guard let llamaContext = self.llamaContext else {
        let faileMessage = strdup("Failed to create text.")
        completion(faileMessage!)
        return
    }
    
    while await llamaContext.n_cur < llamaContext.n_len {
        let result = await llamaContext.completion_loop()
        self.message += "\(result)"
        
        print(result)
    }
    
    let t_end = DispatchTime.now().uptimeNanoseconds
    let t_generation = Double(t_end - t_heat_end) / NS_PER_S
    let tokens_per_second = Double(await llamaContext.n_len) / t_generation
    
    await llamaContext.clear()
    self.message += """
    \n
    Done
    Heat up took \(t_heat)s
    Generated \(tokens_per_second) t/s\n
    """
    
    let messagePtr = strdup(self.message)
    
    completion(messagePtr!)
}

ここの処理が行っているのは各種時間計測と、 LlamaContext によって推論された文字列を結合していく処理になっています。

メインの処理はこの部分ですね。

while await llamaContext.n_cur < llamaContext.n_len {
    let result = await llamaContext.completion_loop()
    self.message += "\(result)"
}

終結果として利用する message プロパティに文字列を足し込んでいっているだけです。ちなみに非同期処理となるため、C#側へはコールバックを用いて結果を返すようにしています。

コールバックを使ってC#側で結果を受け取る

Swift側の非同期処理が挟まるため、結果を返すのにコールバックを用いています。コールバックの定義は以下のようになっています。

public typealias completion_callback = @convention(c) (UnsafeMutablePointer<CChar>) -> Void

上記で定義したコールバックのaliasを利用して関数の引数としてコールバックを受け取ります。

typealias の通り、 completion_callback を新しい型として利用できるように宣言しています。続く @convention(c)C言語の関数呼び出し規約を適用するという意味です。

以下、ChatGPTに聞いて得られた回答です。

@convention(c): この属性は、関数ポインタがC言語の呼び出し規約を使用することを指定します。Swiftはデフォルトで自身の呼び出し規約を持っていますが、この属性を使用することで、C言語との相互運用が可能になります。特に、C言語APIと連携する場合や、C言語のライブラリをSwiftから使用する場合に重要です。

そしてこの型を利用して関数を受け取る関数を定義します。

public func llama_complete(_ pointer: UnsafeMutableRawPointer, _ textPtr: UnsafePointer<CChar>, _ completion: completion_callback) -> Void { }

こうすることで、実行完了後にC#側に結果を渡すことができます。

第一引数に UnsafeMutableRawPointer を受け取っていますが、これは LlamaWrapper クラスのインスタンスへのポインタです。C#から呼び出す際に指定しています。なぜこうする必要があるのかについては前回の記事を参考にしてください。

インスタンスの生成と取り回し

Swift側のクラスのインスタンスはポインタ経由でC#とやり取りするのが基本です。そのため、インスタンスの生成周りについて少しだけ解説しておきます。

インスタンスの生成処理は以下のようになっています。

@_cdecl("create_instance")
public func create_instance(_ pathPtr: UnsafePointer<CChar>) -> UnsafeMutableRawPointer {
    let path = String(cString: pathPtr)
    
    do {
        let llamaContext: LlamaContext = try LlamaContext.create_context(path: path)
        let wrapper: LlamaWrapper = LlamaWrapper(llamaContext: llamaContext)
        return Unmanaged.passRetained(wrapper).toOpaque()
    }
    catch {
        let wrapper: LlamaWrapper = LlamaWrapper()
        return Unmanaged.passRetained(wrapper).toOpaque()
    }
}

この関数がインターフェースとしてC#に公開されており、引数に文字列のポインタを受け取ります。これは推論を実行するモデルへのパスです。そのパスを渡して LlamaContextインスタンスを生成します。生成時に失敗する可能性があるため try-catch していますが、本当であれば失敗時にエラーを通知する仕組みが必要ですが、今回は動かすことだけを目的にしているので細かい制御はしていません。もしエラーが発生したらコンテキストを持たない LlamaWrapper クラスを生成しているだけです。(なので当然、その場合は推論実行時にエラーになります)

無事、コンテキストが生成できたらそれを引数にして LlamaWrapper クラスのインスタンスを生成し、そのポインタを返します。前述の推論用関数の第一引数に渡ってくるのはこのインスタンスになります。

Swift側のクラスのインスタンス生成、それをC#で利用する方法についてのより詳細な内容は前回の記事を参照してください。

edom18.hateblo.jp

C#側の実装

次に、C#側でSwift側にコールバックを渡す方法について見ていきます。

まずは宣言を見てみましょう。

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CompletionCallback(IntPtr resultPtr);

[DllImport(kLibName, CallingConvention = CallingConvention.Cdecl)]
private static extern void llama_complete(IntPtr instance, string text, IntPtr completion);

大事な点は2点。コールバックとして渡すための delegate の宣言時に UnmanagedFunctionPointer(CallingConvention.Cdecl) 属性を付与する点と、デリゲートそのものは IntPtr としてポインタで渡している点です。

実際に渡している処理を見てみましょう。

[MonoPInvokeCallback(typeof(CompletionCallback))]
private static void StaticCallback(IntPtr resultPtr)
{
    _instance.Callback(resultPtr);
}

private void Predict()
{
    _completionCallback = StaticCallback;
    IntPtr completionPtr = Marshal.GetFunctionPointerForDelegate(_completionCallback);
    _gcHandle = GCHandle.Alloc(_completionCallback);

    llama_complete(_llamaInstance, _prompt.text, completionPtr);
}

デリゲートとして渡すメソッドを定義しています。なお、IL2CPPの制約でインスタンスメソッドを渡すことができません。渡せるのはstaticメソッドのみなのでその点に注意です。

また定義時に [MonoPInvokeCallback(typeof(CompletionCallbac))] として属性を付与しています。P/Invokeとして利用できるようにするための処置ですね。


P/Invokeとは

P/InvokeはPlatform Invokeの略です。ドキュメントの説明を引用すると、

P/Invokeは、アンマネージドライブラリ内の構造体、コールバック、および関数をマネージドコードからアクセスできるようにするテクノロジです。P/Invoke APIのほとんどは SystemSystem.Runtime.InteropServices の2つの名前空間に含まれます。これら2つの名前空間を使用すると、ネイティブコンポーネントと通信する方法を記述するツールを利用できます。

つまり、ネイティブ側とやり取りするための規約(API)ということですね。まさにネイティブ側から呼び出されるように設定するため、MonoPInvokeCallback 属性を付けているというわけです。

learn.microsoft.com


そして実際に呼び出す部分を見てみると、上記のstaticメソッドをデリゲート型の変数に設定し、それを Marshal.GetFunctionPointerForDelegate() を利用してポインタ( IntPtr )に変換しています。そしてそれを引数にしてSwift側の実装を呼び出す、という流れになっています。

Swift側の実装は前述の通りです。推論が終わったらその結果をコールバックで返してくれます。あとは受け取った結果をC#側で利用するだけですね。

実プロジェクトで使うには

今回紹介したのは、あくまでllama.cppをUnityで扱う点についてのみです。よく見るとstaticメソッドなため、コールバックした結果を、呼び出したインスタンス側で扱えません。

このあたりはllama.cppをどうやって使うのかの設計にも関わってきます。例えばllama.cppを呼び出すだけのシングルトン的なものを配置して、コールバックはそのインスタンスに登録してやり取りする、などです。ただその場合でも、受け取る側を特定するための準備が必要になるでしょう。

また、Swift側のインスタンスの破棄など実際に使うとなると様々な考慮が必要になります。なので、今回の実装を実プロジェクトでそのまま利用するのはむずかしいでしょう。ただ、利用する方法が分かっていればあとは設計次第なので、より実践的に使えるようにブラッシュアップしていく予定です。

最後に、実装を進める上でハマったポイントをメモしておきます。

ハマったポイント

以下は、今回の実装時にハマったポイントを記載しておきます。

llama.cppの実装を呼び出すとクラッシュする

最初、この実装を始める前に色々llama.cppでiOSネイティブアプリをビルドしたりして調査をしていました。そのときの master ブランチの状態ではUnityに持っていってもクラッシュしなかったのですが、最新版ではなぜかクラッシュするようになってしまいました。

Metal周りの初期化でエラーが出ているようなのですが、なにが原因かはまだ特定できていません。

ちなみに、クラッシュせずに実行できた状態のコミットハッシュは 5bf2b94dd4fb74378b78604023b31512fec55f8f でした。もしご自身で試す場合、同様のエラーが出た場合はこのコミットまで戻してから利用してみてください。

llama_llama.bundleが見つからない

上記クラッシュを回避したあと、無事にアプリが起動したのちに推論を実行したところ、 llama_llama.bundle が見つからないというエラーが発生しました。

これは、Swiftパッケージのビルド時に同時に生成されるバンドルファイルをプロジェクトに追加していなかったのが問題でした。なので、ビルドされたファイルの中にある llama_llama.bundle ファイルをUnityプロジェクトに追加する必要があります。(なお、macOS向けとiOS向けで内容が異なるので、それぞれのプラットフォーム向けに追加・設定する必要があります)

llama_llama.bundle はそれぞれのプラットフォーム向けになるようにインスペクタで設定する必要があります。

SwiftパッケージをビルドしてUnityで扱う



最近はAI関連のニュースが毎日のように飛び込んできますね。MESONでもXR x AIという形でAIにも注力しています。

ChatGPTを筆頭に、LLMはとんでもないスピードで発展しています。今ではローカルで、しかもモバイル上で動くLLMなんかも出てきたりしています。

今回は、iOSでパッケージを利用する方法についてまとめたいと思います。AIの話をしておいてなんでやねん、という感じですがiOS向けに利用できるLLMの環境としてllama.cppがあります。これ以外にもありますが、こうしたものを利用するにもUnityで扱える状態を作らないとならないので、そのために色々調査したものを備忘録としてまとめました。

ちなみにllama.cppのSwift実装はリポジトリexamples 内にあります。

github.com


■ サンプルプロジェクト

このブログ内で紹介しているサンプルプロジェクトをGitHubに上げてあるので、動作がうまくいかない場合などに参考にしてください。

github.com


自作パッケージを作る

ビルドしてUnityで利用するためのSwiftパッケージを作成するところから始めましょう。

パッケージ用に初期化する

まず、パッケージとなるディレクトリを作成し、以下のコマンドを使って初期化します。

$ mkdir SwiftPlugin
$ cd SwiftPlugin
$ swift package init --type library --name SwiftPlugin

上記コマンドを実行すると以下のようにベースとなるファイルなどが生成されます。

Creating library package: SwiftPlugin
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/SwiftPlugin/SwiftPlugin.swift
Creating Tests/
Creating Tests/SwiftPluginTests/
Creating Tests/SwiftPluginTests/SwiftPluginTests.swift

ここで生成された Package.swift をダブルクリックで開くとXcodeが起動し、パッケージとして認識されていることが確認できます。

今回はサンプルのためごく簡単なメソッドだけを実装してみます。

以下のコードを、デフォルトで生成される Sources/SwiftPlugin.swift に追加します。

@_cdecl("calc")
public func calc(a: Int32, b: Int32) -> Int32 {
    return a + b
}

定義の際の大事な点は関数に @_cdecl("関数名") 属性を付けることです。


cdeclについて

cdeclとは「呼び出し規約」と呼ばれるものです。呼び出し規約とは、コンピュータの命令セットアーキテクチャごとに取り決めとして定義されるもので、ABI(Application Binary Interface)の一部です。

Wikipediaから引用させてもらうと以下のように説明されています。

インテルx86ベースのシステム上のC/C++では cdecl 呼出規約が使われることが多い。cdeclでは関数への引数は右から左の順でスタックに積まれる。関数の戻り値は EAX(x86レジスタの一つ)に格納される。呼び出された側の関数ではEAX, ECX, EDXのレジスタの元の値を保存することなく使用してよい。呼び出し側の関数では必要ならば呼び出す前にそれらのレジスタをスタック上などに保存する。スタックポインタの処理は呼び出し側で行う。

最終的に機械語に翻訳されたのち、関数呼び出しというのは「スタックに値を保持したあと、指定の場所に処理をジャンプさせる」という、かなり具体的な処理に変換されます。バイナリインターフェースの名前の通り、その際の「どうやってスタックに積むのか」などの取り決めを行うのがこの呼び出し規約です。

例えば、呼び出し側でスタックへ積む順番を間違えてしまうなどすると、呼び出される側で異なる位置の値を読み込んでしまうことになります。結果的に、引数が適切に渡らないなどの問題につながります。

今回はネイティブプラグインとして利用するため、この規約がなにに従っているのかを知る必要があるために @_cdecl という属性を付けている、というわけですね。C#のところでも解説していますが、C#側も「どう呼び出すか」というのを cdecl を指定して宣言しています。

呼出規約 - Wikipedia


パッケージをビルドする

あまり意味のない実装ですが、分かりやすさ優先です。さて、これをビルドしていきましょう。

Unityでの開発を想定しているため、iOS向けのビルドだけでなくmacOS向けのビルドも行い、Editor上で動くようにもしておきます。

macOS向けにビルドする

今回は共有ライブラリとしてビルドするため、 Package.swift を以下のように修正し、 type.dynamic にしておきます。

// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftPlugin",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "SwiftPlugin",
            // typeを.dynamicにする
            type: .dynamic,
            targets: ["SwiftPlugin"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "SwiftPlugin"),
        .testTarget(
            name: "SwiftPluginTests",
            dependencies: ["SwiftPlugin"]),
    ]
)

macOS向けには以下のコマンドを利用してビルドします。コマンドを実行するのは Package.swift ファイルがあるフォルダ内です。

$ swift build -c release --arch arm64 --arch x86_64

実行すると .build フォルダが生成されその中にライブラリが入っています。(Finderで見ると . 付きフォルダのため見えない場合があります)

生成された中に .dylib の拡張子のものがあるのでこれを利用します。このファイルをUnityプロジェクトにインポートしてください。

macOS向けのビルドは以上です。

iOS向けにビルドする

次に、iOS向けにビルドしていきましょう。

iOS向けには xcodebuild コマンドを利用します。

$ xcodebuild -scheme SwiftPlugin -configuration Release -sdk iphoneos -destination generic/platform=iOS -derivedDataPath ./Build/Framework build

※ 今回のシンプルな状態でもTestターゲット向けに少しエラーが発生してしまいますが、Framework自体は生成されており、さらにちゃんと実機で動作する形になっています。なぜエラーが出るのか、どう解消したらいいのかについては追って調査予定です。

生成された SwiftPlugin.framework フォルダをUnityにインポートします。(フォルダごとです)

Unity上ではしっかりライブラリとして認識されます。(アイコンが変わる)

iOS向けライブラリはEmbed設定をする

iOS向けライブラリは Add to Embedded Binary のチェックを入れておく必要があります。

以上でiOS側の設定も完了です。

Swift側の実装を利用する

続いて、Swift側で定義した処理(ネイティブ側の処理)をC#から利用する方法について見ていきます。

まずはコード全文を載せます。

using System.Runtime.InteropServices;
using TMPro;
using UnityEngine;

public class SwiftPluginTest : MonoBehaviour
{
#if UNITY_EDITOR_OSX
    private const string DLL_NAME = "libSwiftPlugin";
#elif UNITY_IOS
    private const string DLL_NAME = "__Internal";
#endif

#if UNITY_EDITOR_OSX || UNITY_IOS
    [DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)]
    internal static extern int calc(int a, int b);

    [SerializeField] private TMP_Text _text;

    private void Start()
    {
        int result = calc(10, 20);
        
        Debug.Log(result);

        _text.text = result.ToString();
    }
#endif
}

ネイティブプラグインを利用する場合は DllImport 属性を利用し、外部で関数が定義されていることを伝えます。また、Swift側で @_cdelc 呼び出し規約を指定しているので、C#側も CallingConvention.Cdecl を指定します。

詳細についてはUnityのネイティブプラグインの作り方などを参照してください。

ここでの注意点は、macOSの場合はプラグイン名(ファイル名)を指定する必要がありますが、iOSの場合は __Internal を指定する必要がある点です。

関数については宣言だけを行っておけば、実行時はライブラリの関数を参照するようにコンパイルされます。

利用については通常のC#のメソッドと同様に呼び出すだけでOKです。

int result = calc(10, 20);

実行すると、確かにライブラリ側の処理が実行されていることが分かります。(ちゃんと 30 とログが表示されている)

これで、Swift側で実装した内容をC#から(Unityから)呼び出すことができました。iOS用のライブラリも追加してあるので実機にビルドしてもちゃんと動作します。

Swiftのクラスを利用する

さて、関数を単体で定義してそれを呼び出すだけでは、ほとんどの場合そもそもC#だけでも行える可能性が高いです。実プロジェクトでは様々なライブラリの依存や、クラスの利用などが想定されます。

次では、Swift側で定義したクラスをC#側から利用する方法について見ていきます。

まずは以下のようにクラスを定義します。

public class NativeUtility {
    public func add(a: Int32, b: Int32) -> Int32 {
        a + b
    }
    
    public func sub(a: Int32, b: Int32) -> Int32 {
        a - b
    }
}

ただ、C#側からは直接、Swiftのクラス情報にアクセスすることができません。そのため、以下のように、クラス情報を扱うための関数を別途定義します。

@_cdecl("create_instance")
public func create_instance() -> UnsafeMutableRawPointer {
    let utility: NativeUtility = NativeUtility()
    return Unmanaged.passRetained(utility).toOpaque()
}

@_cdecl("use_utility_add")
public func use_utility_add(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 {
    let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue()
    return instance.add(a: a, b: b)
}

@_cdecl("use_utility_sub")
public func use_utility_sub(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 {
    let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue()
    return instance.sub(a: a, b: b)
}

※ 今回はサンプルのため、インスタンスの解放などの処理は書いていませんが、実際のプロジェクトで利用する場合は、インスタンスの破棄などの処理も必要になるでしょう。

なにをしているかざっくり解説すると、 @_cdecl 属性によって extern C のように外部に関数名を公開します。そしてインスタンスの生成とそれを利用する関数を定義しています。

C#から関数へはアクセスできるため、Swift側のクラス情報を「ポインタ」という形でリレーすることで処理を実行するようにしている、というわけです。

なのでこれを利用する場合は create_instance 関数でインスタンスのポインタを生成し、 use_utility_***インスタンスを渡して実行している、というわけですね。( use_utility_*** となっていますが、これは決められた名前ではなく任意の名前を付けることができます。念のため)

ポインタを利用する

関数の内部がややごちゃごちゃしていますが、これはSwift側の参照カウンタなどの処理を活用するための処理です。ひとつずつ見ていきましょう。

まずはインスタンスの生成から。

@_cdecl("create_instance")
public func create_instance() -> UnsafeMutableRawPointer {
    let utility: NativeUtility = NativeUtility()
    return Unmanaged.passRetained(utility).toOpaque()
}

インスタンス生成関数の戻り値は UnsafeMutableRawPointer です。

※ Mutableなので UnsafeRawPointer に変換して利用するほうがより安全かもしれませんが、さらに処理が増えてしまうのでシンプルさ重視で書いています。

まずは普通にインスタンスを生成し、それを Unmanage.passRetained().toOpaque()UnsafeMutableRawPointer に変換して返しています。

次に、実際にクラス(インスタンス)を利用する関数です。

@_cdecl("use_utility_add")
public func use_utility_add(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 {
    let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue()
    return instance.add(a: a, b: b)
}

第一引数に、先ほど生成したインスタンスをポインタとして渡していますね。ポインタを経由していると書いたのはこれが理由です。そして関数内でポインタから元のクラスのインスタンスに変換した上で、利用したいメソッドを実行しているわけです。

Unmanaged<CLASS_NAME>.fromOpaque(<POINTER>).takeUnretainedValue() によってインスタンスを得ています。

ちなみに takeUnretainedValue は、インスタンスを得る際に参照カウンタを増加させずに取得する方法です。いわゆるweak pointer的な感じでしょうか。(あまりSwiftに詳しくないので想像ですが) こうすることで、参照カウンタを増やさずに機能だけを利用することができます。逆に、参照カウンタを得たい場合は takeRetainedValue() を使います。

インスタンスが取得できれば、あとは利用したいメソッドを実行するだけですね。

Swift側のクラスを利用する方法で、Swift側の準備は以上です。

ちなみに込み入ったことをやろうとするとラッパーが増えていくことになりますが、ネイティブな機能を呼び出したいケースというのは局所的であることが多いと思います。もし複雑なことをやりたい場合は、Swift側でさらにラッパークラスを使って、そのクラスの中に処理を詰め込み、C#からは処理の開始などだけを依頼するような形がいいと思います。

C#から呼び出す

Swift側の準備が終わったので、最後にC#からどう利用するかを見ていきましょう。といっても、基本的な作法は前述のものと変わりません。違いは IntPtr を使う点くらいです。

以下は、新しく追記した部分の抜粋です。

[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr create_instance();

[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)]
internal static extern int use_utility_add(IntPtr instance, int a, int b);

[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)]
internal static extern int use_utility_sub(IntPtr instance, int a, int b);

private void Start()
{
    // 中略

    {
        IntPtr instance = create_instance();
        int result2 = use_utility_add(instance, 25, 32);

        Debug.Log(result2);

        _text2.text = result2.ToString();
    }
}

最初のときと同じように DllImport 属性を付与して関数を宣言しています。そして利用する部分は、まずインスタンスを生成し、それをポインタ( IntPtr )として受け取り、それを使って対象のメソッドを呼び出す、という処理になっています。

しっかりと計算されているのが分かります。

以上でSwiftを利用する方法の解説は終わりです。

以下はもう少し発展した使い方などを書いていきます。

Swift側の文字列(String)を扱う

さて、上のサンプルは Int32 型、つまり整数のみを扱っていたのでやり取りはそこまで複雑ではありませんでした。整数などはシンプルなビット配列なのでネイティブ側とのやり取りもシンプルになります。

しかし、文字列など少し込み入った情報をやり取りする場合はそうもいきません。以下は、Swift側で生成した文字列をC#側で扱う方法について見ていきましょう。

Swift側の定義

まずはSwift側での定義を見てみます。

import Foundation // strdupを使うのにこれが必要

public class NativeUtility {
    // 中略
    
    public func version() -> String {
        "1.0.0"
    }
}

@_cdecl("use_utility_version")
public func use_utility_version(_ pointer: UnsafeMutableRawPointer) -> UnsafeMutablePointer<CChar> {
    let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue()
    let version = instance.version()
    return strdup(version)
}

Swift側の文字列を UnsafeMutablePointer<CChar> 型に変換しているのがポイントです。これまたポインタ経由でやり取りするわけですね。ポインタ万能。 ちなみに文字列をポインタに変換するには strdup() 関数を使います。これを利用するためには import Foundation とする必要がある点に注意です。

C#で文字列を受け取る

ではC#でどう文字列を受け取るかを見ていきましょう。

[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr use_utility_version(IntPtr instance);

private void Start()
{
    // 中略

    IntPtr instance = create_instance();
    int result2 = use_utility_add(instance, 25, 32);

    Debug.Log(result2);

    _text2.text = result2.ToString();

    IntPtr strPtr = use_utility_version(instance);
    string result3 = Marshal.PtrToStringAnsi(strPtr);

    Debug.Log(result3);

    _text3.text = result3;

    Marshal.FreeHGlobal(strPtr);
}

例に漏れず関数呼び出しの宣言を追加します。戻り値が IntPtr になっている点に注目です。文字列そのものをやり取りするのではなく、いったんポインタを経由するのはインスタンスのやり取りと同じですね。文字列のインスタンスをやり取りする、と考えると分かりやすいでしょう。

そして取得した文字列ポインタをC#の文字列に変換します。変換するには Marshal.PtrToStringAnsi(<POINTER>) を使います。戻り値はC#string なのであとはそのまま利用するだけですね。

注意点として、文字列を使い終わったら Marshal.FreeHGlobal(<POINTER>) を実行して解放してやる必要があります。

C#から文字列を送る

ではC#から文字列を送る場合はどうでしょうか?

まずはSwiftの定義から見ていきましょう。

public class NativeUtility {
    // 中略
    
    public func stringDecoration(str: String) -> String {
        "Decorated[\(str)]"
    }
}


@_cdecl("use_utility_decorate")
public func use_utility_decorate(_ pointer: UnsafeMutableRawPointer, _ text: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar> {
    let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue()
    
    let string = String(cString: text)
    let result = instance.stringDecoration(str: string)
    return strdup(result)
}

文字列を返す場合は UnsafeMutablePointer<CChar> 型でしたが、受け取る場合は UnsafePointer<CChar> 型で受け取ります。

Swiftの String 型への変換は String のコンストラクタにポインタを渡すだけですね。あとは普通に String として利用するだけです。

今回のサンプルでは加工した文字列を返しているので、前述の、C#へ文字列を返す方法をそのまま利用しています。


C#とSwift間で整数(プリミティブ)、文字列、インスタンスのやり取りの方法の解説は以上です。

最後に、ネイティブ側からC#のコードを呼び出すコールバックについて解説します。少しだけ準備が増えます。

ネイティブ側からC#の関数を呼ぶ。コールバックの実装

今自分が実装を試みているのが、Swift側で非同期処理があるパターンです。そのため、処理が終わってからC#側の処理を呼び出さないとなりません。当然、C#側の async / await は利用できないのでコールバックという形で結果を受け取ります。

まず、SwiftとC#双方でコールバック(関数ポインタ)の型を宣言する必要があります。順番に見ていきましょう。

Swift側でコールバックの型を宣言

まずはSwift側から見てみましょう。

public typealias completion_callback = @convention(c) (Int32) -> Void

@_cdecl("async_test")
public func async_test(_ completion: completion_callback) -> Void {
    DispatchQueue.global().async {
        completion(35)
    }
}

今回はネイティブ側から整数を送るだけのものを宣言しています。そしてそれを受け取る関数を定義し、処理が終わったのちに関数として呼び出しています。

C#側でもコールバックの型を宣言

次にC#側を見てみましょう。

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CompletionCallback(int result);

[DllImport(kLibName, CallingConvention = CallingConvention.Cdecl)]
private static extern void async_test(IntPtr callback);

private CompletionCallback _completionCallback;
private GCHandle _gcHandle;

private void Callback(int result)
{
    Debug.Log(result);

    // コールバックを渡す関数を実行する前に `GCHandle` を取得しているので、それを解放する。
    _gcHandle.Free();
}

private void CallWithCallback()
{
    _completionCallback = Callback;
    IntPtr pointer = Marshal.GetFunctionPointerForDelegate(_completionCallback);
    _gcHandle = GCHandle.Alloc(_completionCallback);

    async_test(pointer);
}

C#側の準備はちょっと多めになります。まず、コールバックの型を宣言しています。宣言の際に UnmanagedFunctionPointer 属性を付与しています。

そして delegate を保持する変数を準備し、さらにガベージコレクションされないように GCHandle の変数も用意します。あとはコールバックそのものを定義していますね。

こうして準備したものを用いてコールバックを渡して実行します。

delegateIntPtr に変換してから渡しているのがポイントです。変換には Marshal.GetFunctionPointerForDelegate(<DELEGATE>) を使います。

また GCHandle を取得したのちにネイティブ側の関数を呼び出しています。

最後に、コールバックが呼ばれたら確保したハンドルを解放して終わりです。

文字列やクラスのインスタンスと同様、ネイティブ側とのこうしたやり取りには基本的にポインタを利用する、と覚えておくといいでしょう。

GCHandleが必要な理由

なぜこの GCHandle.Alloc を行っているのでしょうか。これはマネージドコード特有の問題です。C#は定期的にガベージコレクタによってメモリの解放が行われます。この際、場合によってはメモリの最適化のためにポインタの位置が変更される可能性があります。もし非同期処理の実行後にコールバック呼び出しのために、変更前のアドレスにアクセスしてしまうと当然、クラッシュの要因となってしまいます。

これを避けるために GCHandle を利用しているというわけです。


最後に、パッケージ間の依存について話をして今回の記事を終わりにしたいと思います。

依存のあるパッケージについて

今回のサンプルではひとつのパッケージのみを作成しました。しかし、パッケージには依存関係を定義することができます。

例として新しく SwiftPluginWrapper というパッケージを作成し、依存関係を作ってみます。

コマンドで生成された Package.swiftPackagedependencies を追加しリソースの場所を指定します。そして targets にも dependencies を追加してパッケージ名を指定します。

// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftPluginWrapper",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "SwiftPluginWrapper",
            type: .dynamic,
            targets: ["SwiftPluginWrapper"]),
    ],
    dependencies: [
        .package(path: "../SwiftPlugin"),
        // GitHub上のパッケージなども指定することができる
        // .package(url: "https://github.com/<USER_NAME>/<REPOSITORY_NAME>.git", branch: "main"),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "SwiftPluginWrapper",
            // リソースの場所だけでなく、依存関係として `dependencies` を追加する。追加するのはパッケージ名
            dependencies: ["SwiftPlugin"]),
        .testTarget(
            name: "SwiftPluginWrapperTests",
            dependencies: ["SwiftPluginWrapper"]),
    ]
)

するとこのパッケージの依存関係が定義でき、依存先のパッケージの内容を利用することができるようになります。

ラッパーパッケージ側を開くと、 Package Dependencies として SwiftPlugin が認識されているのが分かります。

実際に、指定したパッケージを利用するようにしてコードを書いてみます。

import SwiftPlugin

@_cdecl("add")
public func add(_ a: Int32, _ b: Int32) -> Int32 {
    let utility: NativeUtility = NativeUtility()
    
    let result: Int32 = utility.add(a: a, b: b)
    
    return result
}

add 関数の中で SwiftPlugin 側の NativeUtility クラスをインスタンス化しているのが分かります。

これをビルドしてUnity側で利用してみましょう。

依存関係があるパッケージをビルドすると、依存先(GitHub上のパッケージなど)も自動的に追加されビルド対象となります。

ちなみにローカルの場合はふたつのパッケージがそれぞれビルドされました。GitHub上のものを指定したときはひとつのライブラリになったので、依存のさせた方によって挙動が異なるかもしれません。適宜、生成されたライブラリをインポートするようにしてください。

上記の設定でビルドした結果が以下です。ふたつのパッケージ(libSwiftPluginlibSwiftPluginWrapper)の .dylib ファイルができているのが確認できます。

iOS向けも同様に SwiftPluginSwiftPluginWrapper.framework が生成されています。

これの使い方は前述の通りです。 .dylib および .framework をUnityにインポートし、 DllImport 属性を付与した関数を定義すれば利用できます。

以下は実行した結果です。

ラッパーライブラリ側から、 libSwiftPlugin 内のコードも正常に呼び出せていることが確認できます。

注意点メモ

Unityはリロードが必要

Unityは、ライブラリを一度ロードするとリロードしてくれません。そのため、今回のように徐々に機能を作っていく場合、新しくビルドしたライブラリを入れ直しても処理が更新されません。反映させたい場合はUnityを再起動してください。

パッケージの依存が解決できない

GitHub上のパッケージを指定した場合、基本的にはなにもしなくても利用可能になりますが、GitHubリポジトリ名とパッケージ名が一致していない場合、パッケージの依存関係が解決できずにエラーになってしまう現象がありました。

その場合は以下のように dependencies を設定すれば解決できます。

// GitHub上のパッケージを指定
dependencies: [
    .package(url: "https://github.com/ggerganov/llama.cpp.git", branch: "master"),
],

// dependenciesには `.product` を利用して名前とパッケージ名を指定
.target(
    name: "llamacpp-wrapper",
    dependencies: [
        .product(
            name: "llama",
            package: "llama.cpp")
    ]),

応用編

SwiftではなくC++になってしまいますが、NDIというネットワークで映像を配信できるフレームワークC++実装を、Unityで扱えるようにした記事を以前に書きました。実際に存在しているフレームワーク・ライブラリをUnityで扱えるようにしたものなので、今回のサンプルではなく実際に利用する際の実装の仕方の参考になると思うので、興味があったらぜひ読んでみてください。

edom18.hateblo.jp

まとめ

Swiftパッケージはかなり簡単にライブラリ化することができます。とはいえ、今回のようなシンプルな構成ではないとビルド時にエラーなどが発生するかもしれません。しかし、基本となる部分をおさえておけば、問題が起きたときの切り分けも簡単になるでしょう。

最近ではApple Vision Proが発売され、iOSのみならずvisionOSも登場しました。今後、Swiftを利用してvisionOS向けのライブラリなども作ることになっていくと思います。特にApple Vision Pro開発はUnityではかゆいところに手が届かないことも多々あります。

そのときはSwift側で機能を実装して、それをUnity(C#)から呼び出す、という構成を取る必要が出てくることもあり得るでしょう。そんなときに参考にしてみてください。

QuestのパススルーとVR Room機能を利用してMixed Realityを実現する

概要

Questのパススルーの機能が拡充され、MRアプリとして色々と利用できるようになっているので、その機能を利用するためのメモを書いていきます。具体的には、以下の動画のように、自分で設定したオブジェクト(机や椅子、本棚など)を制御して「自分の部屋をVR空間にしていく」ための方法のメモです。

ちなみにこの機能を実装するにあたって、Metaが提供している The World Beyond を参考にしました。さらにうれしいことにそのUnityプロジェクトはGitHubに公開されているので興味がある方はぜひ見てみてください。

github.com

動作サンプル

また、今回のサンプルの機能部分(パススルーの有効化など)についてはGitHubにアップしてあるので、実際に動くものを見たい場合はこちらをダウンロードして確認ください。

github.com



パススルーの設定

まず、冒頭の動画のようなコンテンツを作成する場合、ビデオパススルーを有効化する必要があります。いくつか手順が必要で、特にエラーなども出ないので注意が必要です。

ここでは必要な要素のみを取り上げますが、以前に、より詳細な記事を書いているのでそちらも合わせて参照ください。

※ なお、以下の記事を書いたときはまだ実験的機能だったために必要だった処理もありましたが、現在はいくつかは行わなくても大丈夫になっているようです。

edom18.hateblo.jp

パススルーを有効化する

パススルーを有効化するには以下の手順を実行してください。

Player Settings にて以下を設定します。

  • Scripting Backendを IL2CPP にする
  • Target Architectureを ARM64 にする

OVRCameraRig PrefabにあるコンポーネントOVRManager の以下の項目を設定します。

  • Anchor SupportEnabled にする
  • Passthrough Capability Enabled のチェックをオンにする
  • Enable Passthrough のチェックをオンにする


※ 画像内の Quest Features は厳密には OVRProjectConfig という ScriptableObject でできたアセットの設定を OVRManager が分かりやすく表示しています。そのため、本来の設定はそのアセットに保存されます。


  • OVRCameraRig オブジェクトに OVRPassthroughLayer コンポーネントを追加する
  • OVRPassthroughLayerPlacementUnderlay に変更する


上記までを設定することでビデオパススルーが有効化され、利用できるようになります。

各リアルオブジェクトを利用する

次に、リアルオブジェクト(VR Room機能で設定した壁や窓など)をシーン内に表現するための設定を行います。

OVRSceneManager をシーンに配置する

Meta XR Utilities に含まれている OVRSceneManager プレファブをシーン内に配置します。設置したら所定のパラメータに適切にオブジェクトを設定します。(詳細は後述します)

OVRSceneModelLoaderをアタッチする

OVRSceneManager をアタッチしたオブジェクトに、追加で OVRSceneModelLoader をアタッチします。

リアルオブジェクトを表す OVRSceneAnchor

OVRSceneManager のインスペクタに設定するオブジェクト(Prefab)は OVRSceneAnchor コンポーネントを持っている必要があります。このコンポーネントがリアルオブジェクトを表す単位となります。スクリプトのコメントを引用すると以下のように説明されています。

A scene anchor is a type of anchor that is provided by the system. It represents an item in the physical environment, such as a plane or volume. Scene anchors are created by the OVRSceneManager.


シーンアンカーはシステムから提供されるアンカーのタイプです。物理環境の平面やボリュームなどのひとつのアイテムを表現します。シーンアンカーは OVRSceneManager によって生成されます。

上記画像の意味をひとつひとつ見ていきましょう。

平面用Prefab Plane Prefab

ひとつ目の項目は Plane Prefab です。これはリアルオブジェクトのうち、平面として表されるオブジェクト用に利用されます。例えば机や椅子などの平面です。また壁や天井なども平面で定義されるため、このPrefabが利用されます。( Instantiate される)

ボリューム用Prefab Volume Prefab

もうひとつの Volume Prefab は、ボリューム、つまり体積を持つ単位で利用されるPrefabです。例えばVR Room機能で Other で設定され、VR Roomのプレビューで立方体で表されるオブジェクトがこれに該当します。

それぞれのPrefabはオーバーライドできる

最後の Prefab Overrides は、壁や天井など、特定のリアルオブジェクトを専用のPrefabでオーバーライドするためのものです。例えば、床はこのPrefabを利用して特殊なテクスチャが貼ってあるようにする、などの使い方ができます。

Prefabの構成

OVRSceneManager に設定するPrefabの構成は以下の通りです。

Plane Prefabの構成

Plane Prefab に設定しているPrefabは以下の構成になります。トップオブジェクトに OVRSceneAnchor を付け、その下にMeshを配置しているだけですね。ただ、Meshに設定するマテリアルは Transparent のQueueより少し早めに描画しておく必要があるようです。

Volume Prefabの構成

Volume Prefab に設定しているのは以下の構成になります。 Plane Prefab と同様、トップオブジェクトに OVRSceneAnchor を付け、その下に Parent > Mesh という階層でオブジェクトを配置しています。 ParentコンポーネントがなにもないオブジェクトでおそらくPivot的に使われるものと思われます。そして最下層のMeshは Plane Prefab と異なり、Cube型のオブジェクトを配置しています。

Prefab Overridesに設定するPrefabの構成

最後に、オーバーライドするPrefabの構成についてです。こちらは以下のような構成になっていました。上記構成と共通の設定として、 OVRSceneAnchor を追加し、逆に上記の構成と異なる点としては OVRScenePlaneMeshFilter コンポーネントを追加しています。Meshに設定しているマテリアルは上記のものと同様です。

マテリアルについて

Prefabに設定しているマテリアルですが、Queue以外に色を調整する必要があります。具体的には、

  • 色を黒にする
  • アルファを0にする(= 完全透明にする)

理由としては、色は加算されるため白にすると真っ白になってしまいます。そしてアルファの値はパススルー映像の透過させるために0にしておく必要があります。

壁を透過させてVRのように見せる

冒頭の動画のように、パススルーで見ているところを透過してVRのように見せる方法はとてもシンプルです。生成された壁のオブジェクトは Plane Prefab に登録された汎用なものか、あるいはオーバーライドされたPrefabで構成されています。Planeの名前の通りただの平面オブジェクトなのでそれを「非表示」にしてしまえば遮蔽するものがなくなり、その奥に広がっているVR空間が顔を出す、というわけです。

また、オーバーライドできることを利用して、例えば壁には特殊なコンポーネントを付与しておき、ポイントされたら円形の形にくり抜いて奥を見せる、などの細かな制御を行うことができるでしょう。

まとめ

実際に触ってみて思ったのは、大部分のところをMeta XR Utility側でやってくれるので本当に必要なところだけを実装するだけでOKでした。大まかな仕組みは理解したのでこれを利用してコンテンツをひとつ作ってみようと思います。

実際のコンテンツの場合は「どのドア」とか「どの窓」など複数登録できるオブジェクトをどうやって有効利用するかを決めないとならないのでもうひと手間かかるかなとは思っています。とはいえ、大枠として「ドアがここ」などが分かるのはMRコンテンツを作成する上でかなり大きな意味を持つと思います。ぜひみなさんも面白いアイデアが思いついたら実装してみてください。

XR Plug-in ManagementによるXR機能(サブシステム)の仕組みを追う

概要

普段の開発でクロスプラットフォームの対応をよくしている関係で、XR Plug-in Managementの仕組みに興味を持って調べてみました。今回はXR機能(以後、サブシステム)の仕組みについてまとめていきたいと思います。

ちなみに以下の画面で設定するプロバイダと実際にそれを使う仕組みのことです。

今回の調査にあたって以下のリポジトリの実装を参考にさせていただきました。

github.com



全体のフロー

大きく分けて3つの流れがあります。

  1. LoaderやSettingsなど、サブシステムを提供するにあたっての情報を定義する
  2. 指定されたLoaderからサブシステムを取得、実行時に利用できるようにセットアップする
  3. 実行時にサブシステムを取得、生成して利用する

(1)については概要に載せたウィンドウなどで設定を行えるようにするもので、(2)と(3)については実行時に処理されるものとなっています。

ということで、まずは最初のLoaderとSettings周りについて見ていきましょう。

サブシステムの設定

設定についてはさらに以下2つのパートに分けることができます。

  1. Project Settingsに項目を表示する
  2. パッケージのセットアップ(必要なパッケージのダウンロード設定など)

まずはシンプルな(1)から見ていきます。

Project Settingsに項目を表示する

XR Plug-in Management をインストールするとその下にさらに複数の項目が表示されます。(冒頭の図では ARCore が表示されています)

まずはここに表示するための処理を見ていきましょう。

XRConfigurationDataAttribute

実はこれ自体はとてもシンプルです。 XRConfigurationDataAttribute という属性が定義されており、以下のように定義するだけで自動的に認識され、 XR Plug-in Management の項に追加されます。

using UnityEngine;
using UnityEngine.XR.Management;

[System.Serializable]
[XRConfigurationData("TestXRMock", "com.edo.testxrmock.loader")]
public class TestXRMockLoaderSettings : ScriptableObject
{
    [SerializeField] private bool _hoge = true;
}

このスクリプトをプロジェクトに追加すると以下のように自動的に項目が表示されます。ちなみに属性の第一引数が項目名、第二引数がIDになっており、他とかぶらないユニークなIDを指定する必要があります。

パッケージのセットアップ

次に、 XR Plug-in Management から設定を行うと自動的にアセットなどのダウンロードを行ったり、利用するデータの設定を行うための実装を行います。

これを実装すると以下のように、Providerの一覧に表示されるようになります。

ここで重要になってくるインターフェースが以下です。

  • IXRPackage
  • IXRPackageMetadata
  • IXRLoaderMetadata

特に、 IXRPackage インターフェースを実装することで自動的にProviderのリストに表示されるようになります。残りのふたつのインターフェースはメタデータを表現し、例えばどのプラットフォーム向けに提供するProviderなのか、などの定義を行います。

今回のために作ったサンプルはコード量はそこまで多くないのでまずは全文を載せてしまいましょう。

using System.Collections.Generic;
using UnityEditor;
using UnityEditor.XR.Management.Metadata;
using UnityEngine;

internal class TestXRMockPackage : IXRPackage
{
    private class TestXRMockLoaderMetadata : IXRLoaderMetadata
    {
        public string loaderName { get; set; }
        public string loaderType { get; set; }
        public List<BuildTargetGroup> supportedBuildTargets { get; set; }
    }

    private class TestXRMockPackageMetadata : IXRPackageMetadata
    {
        public string packageName { get; set; }
        public string packageId { get; set; }
        public string settingsType { get; set; }
        public List<IXRLoaderMetadata> loaderMetadata { get; set; }
    }

    private static IXRPackageMetadata s_metaData = new TestXRMockPackageMetadata()
    {
        packageName = "Test XR Mock",
        packageId = "com.edo.test-xr-mock",
        settingsType = typeof(TestXRMockLoaderSettings).FullName,
        loaderMetadata = new List<IXRLoaderMetadata>()
        {
            new TestXRMockLoaderMetadata()
            {
                loaderName = "Test XR Mock",
                loaderType = typeof(TestXRMockLoader).FullName,
                supportedBuildTargets = new List<BuildTargetGroup>
                {
                    BuildTargetGroup.Android,
                    BuildTargetGroup.Standalone,
                },
            }
        }
    };

    public bool PopulateNewSettingsInstance(ScriptableObject obj)
    {
        TestXRMockLoaderSettings loaderSettings = obj as TestXRMockLoaderSettings;
        if (loaderSettings == null)
        {
            return false;
        }

        return true;
    }

    public IXRPackageMetadata metadata => s_metaData;
}

特に重要な部分は以下の IXRPackageMetadata を生成している部分です。ここで、どう画面に表示されるのか、どのアセットを必要とするのかを定義します。

private static IXRPackageMetadata s_metaData = new TestXRMockPackageMetadata()
{
    packageName = "Test XR Mock",
    packageId = "com.edo.test-xr-mock",
    settingsType = typeof(TestXRMockLoaderSettings).FullName,
    loaderMetadata = new List<IXRLoaderMetadata>()
    {
        new TestXRMockLoaderMetadata()
        {
            loaderName = "Test XR Mock",
            loaderType = typeof(TestXRMockLoader).FullName,
            supportedBuildTargets = new List<BuildTargetGroup>
            {
                BuildTargetGroup.Android,
                BuildTargetGroup.Standalone,
            },
        }
    }
};

packageName は画面に表示される名前なので説明不要でしょう。

続く packageId は、参照する(実際に機能を提供する)アセットのパッケージIDを指定します。Providerのチェックを入れた際に、もしまだプロジェクトにパッケージがない場合は自動的にダウンロードされます。

例えばARCoreであれば、ARCore Pluginパッケージが自動的にダウンロードされる、という具合です。


ローカルパッケージを利用する

もしレジストリを利用しているわけではなく、機能をローカルで持っている場合はローカルのアセットとしてPackagesに追加すれば利用することができます。 package.json にローカルのパッケージを登録したあとに、そのIDを指定すればOKです。


settingsType は前述した XRConfigurationDataAttribute を付与したクラスの FullName を指定します。これはシステムが自動でアセットを生成するために必要となります。

上記までを実装し、XR Plug-in ManagementでProviderにチェックを入れると以下のようにアセットが生成されます。

※ まだローダについては説明していません。実際に表示するためにはローダの実装も必要です。ローダについては後述します。以下のキャプチャで Test XR Mock Loader となっている部分です。

サブシステムのセットアップ

前述の設定周りの実装により、XR Plug-in Managementの画面に表示されるようになり、さらにサブシステムを生成するための準備が整いました。ここからは、実際にモックのサブシステムの実装を見ながら、セットアップのフローを見ていこうと思います。

前述の設定内に TestXRMockLoader のクラス名の記述がありました。Loaderの名前が示す通り、このアセットが具体的なサブシステムのロード(つまり生成)を司ります。

詳細は後述しますが、実行時にはアクティブなLoaderを取り出し、そのLoaderの初期化処理を呼び出す仕組みになっています。つまり、これから説明するLoaderの実装はその初期化のタイミングで呼び出される処理となります。

Loaderの実装

興味深いことに、Loaderは ScriptableObject を継承したベースクラスがあり、設定などについてはファイルとして保存されています。前述の画像を再掲すると Assets/XR/Loaders に自動的に保存されるファイルがそれです。ARCoreやARKitを利用したことがある人は自動的にファイルが生成されているのを見たことがあると思います。ここではまさに、このLoaderの実装を行っていく、ということになります。

Loader実装の予備知識

実際の実装をしていく前に、いくつか予備知識を確認しておきましょう。

前述の通り、Loaderのベースクラスは ScriptableObject です。継承関係は以下のようになっています。

実は XRLoaderabstract クラスになっていて、これを継承した XRLoaderHelper というクラスが存在します。Helperの名前の通り、サブシステムの構築において便利なメソッドなどが定義されているクラスです。そのため、自作のLoaderを作成する場合はこの XRLoaderHelper を継承することになります。

具体的にどんな内容があるか簡単に見ておくと、以下のようなメソッドが定義されています。

  • T GetLoadedSubsystem<T>();
  • void StartSubsystem<T>();
  • void CreateSubsystem<TDescriptor, TSubsystem>(List<TDescriptor> descriptors, string id);

なんとなくどういうことをやってくれるクラスかが見えてくるかと思います。
ちなみに XRLoaderHelperabstract クラスになっています。


さて、では実際に実装を見ていきましょう。

初期化処理

まず最初に見るのは初期化処理です。いくつかの処理を経てLoaderの Initialize() メソッドが呼び出されるようになっています。(メソッドが呼び出されるまでのフローは後述)

Loaderの名前が示す通り、 Initialize() メソッド内で各機能を提供するサブクラスの生成を行います。

ごくシンプルな初期化処理の実装コードを載せます。以下では XRSessionSubsystemXRCameraSubsystem のみを生成するサンプルコードとなっています。実際には、提供する機能のサブシステム分の実装が必要となります。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.Management;
using UnityEngine.XR.ARSubsystems;

public class TestXRMockLoader : XRLoaderHelper
{
    private static List<XRSessionSubsystemDescriptor> s_sessionSubsystemDescriptors = new List<XRSessionSubsystemDescriptor>();
    private static List<XRCameraSubsystemDescriptor> s_cameraSubsystemDescriptors = new List<XRCameraSubsystemDescriptor>();

    // ↑必要なサブシステムの数分、これらのListを定義する必要がある。

    public override bool Initialize()
    {
        XRSessionSubsystem sessionSubsystem = GetLoadedSubsystem<XRSessionSubsystem>();

        if (sessionSubsystem != null)
        {
            return true;
        }

        Debug.unityLogger.Log("xr-mock", $"Initializing {nameof(TestXRMockLoader)}");

        CreateSubsystem<XRSessionSubsystemDescriptor, XRSessionSubsystem>(s_sessionSubsystemDescriptors, typeof(TestXRMockSessionSubsystem).FullName);
        CreateSubsystem<XRCameraSubsystemDescriptor, XRCameraSubsystem>(s_cameraSubsystemDescriptors, typeof(TestXRMockCameraSubsystem).FullName);
        
        sessionSubsystem = GetLoadedSubsystem<XRSessionSubsystem>();
        if (sessionSubsystem == null)
        {
            Debug.unityLogger.LogError("xr-mock", "Failed to load session subsystem.");
        }

        return sessionSubsystem != null;
    }
}

上記の初期化処理はランタイム時にLoaderが決定され(*1)、選択されたLoaderの Initialize() メソッドが呼び出されます。ここで行っている処理は CreateSubsystem<T1, T2>() メソッドを利用してサブシステムを生成することです。このメソッドはヘルパーである XRLoaderHelper クラスで実装されており、後述するDescriptorとIDを指定することでインスタンスを生成しています。

*1 ... 複数のLoaderが設定できるため、有効なひとつのLoaderを選択する仕組みになっている。

ちなみに生成に関しては Activator.CreateInstance(System.Type type); メソッドを利用しています。生成している箇所を抜粋してみましょう。

// SubsystemDescriptorWithProvider`2.cs

public TSubsystem Create()
{
    if (SubsystemManager.FindStandaloneSubsystemByDescriptor((SubsystemDescriptorWithProvider) this) is TSubsystem subsystemByDescriptor)
    return subsystemByDescriptor;
    TProvider provider = this.CreateProvider();
    if ((object) provider == null)
    return default (TSubsystem);
    TSubsystem subsystem = this.subsystemTypeOverride != null ? (TSubsystem) Activator.CreateInstance(this.subsystemTypeOverride) : new TSubsystem();
    subsystem.Initialize((SubsystemDescriptorWithProvider) this, (SubsystemProvider) provider);
    SubsystemManager.AddStandaloneSubsystem((SubsystemWithProvider) subsystem);
    return subsystem;
}

ん? Descriptor? と思っていた人もいるかもしれません。XR Plug-in Managementシステムの特徴として、Descriptorによってサブシステムを生成し、Providerによって実機能を提供するという構造になっています。なので CreateSubsystem メソッドでも、型引数に指定しているのは Descriptor でした。

別の言い方をするとサブシステムは、

  • Descriptorによって定義され、
  • Providerによって機能を提供し、
  • ISubsystem インターフェースによって機能を公開する

となります。

サブシステムは ISubsystem インターフェースを実装したものを期待されており、ARFoundationなどの高APIから低レイヤーの機能を呼び出すためのインターフェースになっているわけです。

Descriptorによるサブシステムの登録

前段ではDescriptorという名前が出てきました。Descriptorはサブシステムごとに用意することになっておりサブシステムの生成処理を担います。

なぜDescriptorが生成処理を担うかというと、システムの裏には SubsystemDescriptorStore というクラスが存在しており、 Store の名前の通りDescriptorを複数保持する形になっています。実はLoaderの初期化処理よりも前にDescriptor郡がすでに多数登録されており、初期化のタイミングで対象のDescriptorを取り出して生成を依頼する、という形になっているのです。リストにまとめるとフローは以下の通り。

  1. 各サブシステムのDescriptorを SubsystemDescriptorStore へ登録する
  2. システムが適切なLoaderを決定する
  3. 選択されたLoaderが各サブシステムを、Descriptorを通じて生成する
  4. 各サブシステムを利用するクラス(*2)にDIする

*2 ... SubsystemLifecycleManager クラスを継承した ARCameraManager などがあります。

ちなみに(4)のDI部分ですが、ジェネリクスによってそれを実現しています。詳細については後述します。

登録処理はRuntimeInitializeOnLoadMethodAttributeを使用

前述のDescriptorの登録処理は、各サブクラスが利用される前に登録が完了していないとならないため、かなり早いタイミングで行われています。これを実現するために [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] をstaticメソッドに付与し、シーンのロードなどよりも早い段階で処理されるようになっています。

今回実装した独自クラスの実装部分を掲載します。

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
internal static void Register()
{
    XRSessionSubsystemDescriptor.RegisterDescriptor(new XRSessionSubsystemDescriptor.Cinfo
    {
        id = typeof(TestXRMockSessionSubsystem).FullName,
        providerType = typeof(MockProvider),
        subsystemTypeOverride = typeof(TestXRMockSessionSubsystem),
        supportsInstall = false,
        supportsMatchFrameRate = false,
    });
}

RuntimeInitializeOnLoadMethod はその名前の通り、ランタイム時の初期化処理を実装するために指定する属性です。さらに引数にはいくつかのタイプがあり、ここで指定している RuntimeInitializeLoadType.SubsystemRegistration は、この属性の中でも一番早く実行されます。これによって、シーンロードの前、サブシステムが実際に必要になるタイミングよりも早い段階でDescriptorの登録が完了している、というわけなんですね。

Descriptorの登録にはそのDescriptorの中で定義されている Cinfo 構造体を用いて登録しています。フィールドは主に、そのサブシステムが期待されている機能リストで、作成しているサブシステムがどの機能をサポートするか、などの情報を指定するようになっています。また一番大事な部分として idproviderTypesubsystemTypeOverride の指定があります。

id はLoaderが名前解決に利用するIDとなっていて、同じIDのものが選択されインスタンス化されます。またインスタンス化されるサブシステムとProviderはそれぞれ subsystemTypeOverrideproviderType で指定したクラスです。そのためこのみっつは、独自実装したクラスと紐づける必要があります。

XRSessionSubsystemDescriptor.RegisterDescriptor という名前から推測できる通り、ARFoundationなどが期待する各XR関連のサブクラスがそれぞれ用意されており、さらにそれぞれにDescriptorが存在しています。登録処理はそのベースクラスとなる XR****SubsystemDescriptor クラスが担当してくれるため、基本的にはそれらを利用するだけで済むでしょう。

Loaderの決定

今まではDescriptorの登録やLoaderの初期化処理について書いてきました。ここでは、そもそもLoaderはどう決定されるのかについて見ていこうと思います。

ちなみにLoaderの決定がなにを意味しているかというと、XR Plug-in Managementは複数のLoaderを登録することができるようになっています。つまり、「どのLoaderを採用すべきか」を決定しないとならないということです。

XR Plug-in ManagementウィンドウのPlug-in Providersの数だけLoaderがあると考えるといいでしょう。そして以下の画像を見てわかる通り、チェックボックスのため複数設定することができるようになっています。この中から、どのLoaderを使うべきか、を判定する必要があるというわけです。

Loaderの処理フロー

Loaderの処理フローを見てみましょう。

※ ちなみに、Loaderの処理フローはEditorとビルド後で挙動が異なります。ここで解説するのはあくまでビルドされたあとの話となります。

  1. XRGeneralSettingsAwake (*3)で s_RuntimeSettingsInstancethis を設定し、どこからでも参照できるようにする(シングルトン)
  2. XRGeneralSettings.AttemptInitializeXRSDKOnLoad()[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] にて呼び出し
  3. (2)を経て、XRGenralSettingsInitXRSDK() インスタンス・メソッドの呼び出し
  4. XRGeneralSettings が保持している XRManagerSettingsInitializeLoaderSync() インスタンス・メソッドの呼び出し

*3 ... XRGeneralSettings は以下のようにアセット化されているものです。 ScriptableObject なので Awake はかなり早いタイミングで実行されます。

という流れを経て、最後の InitializeLoaderSync() により、アクティブなLoaderが決定されます。実際に処理を行っている部分を見てみると以下のようになっています。

public void InitializeLoaderSync()
{
    if (activeLoader != null)
    {
        Debug.LogWarning(
            "XR Management has already initialized an active loader in this scene." +
            " Please make sure to stop all subsystems and deinitialize the active loader before initializing a new one.");
        return;
    }

    foreach (var loader in currentLoaders)
    {
        if (loader != null)
        {
            if (CheckGraphicsAPICompatibility(loader) && loader.Initialize())
            {
                activeLoader = loader;
                m_InitializationComplete = true;
                return;
            }
        }
    }

    activeLoader = null;
}

登録されているLoaderの中からひとつを取り出して設定、初期化処理を実行しているのが確認できます。 loader.Initialize() の部分が、前述のLoaderの初期化処理部分ですね。こうしてサブシステム郡が生成される、というわけです。

ちなみに、 currentLoadersm_LoaderManagerInstance はともに SerializeField になっており、Editor側でそれを設定したものをビルド時に含めていると思われます。そのため、ビルド後は設定処理がされていません。

サブシステムの実装

ここからは、実際に各サブシステムをどう実装すればいいのかについて見ていきます。

まず把握するべき点として、サブシステムを構成するクラス図があります。例として XRSessionSubsystem のクラス図を図にすると以下のようになります。

図中の SubsystemWithProvider_3 は実際にはジェネリクス版のクラスとなっていますが、PlantUMLでそれが描けなかったので _3 で代用しています。実際のクラス定義は以下となっています。

public abstract class SubsystemWithProvider<TSubsystem, TSubsystemDescriptor, TProvider> : 
  SubsystemWithProvider
  where TSubsystem : SubsystemWithProvider, new()
  where TSubsystemDescriptor : SubsystemDescriptorWithProvider
  where TProvider : SubsystemProvider<TSubsystem>
{
    // 略
}

サブシステムクラスの構造

自分ははじめ、なぜ WithProvider という名前がついているのだろうと疑問に思っていました。しかし理解するとなんのことはない、サブクラス内で一緒に Provider を定義することを明示していただけなのでした。

図に取り上げた XRSessionSubsystem の実装を見てみると以下のようになっています。

public class XRSessionSubsystem
    : SubsystemWithProvider<XRSessionSubsystem, XRSessionSubsystemDescriptor, XRSessionSubsystem.Provider>
{
    // ... 中略 ...

    /// <summary>
    /// The API this subsystem uses to interop with
    /// different provider implementations.
    /// </summary>
    public class Provider : SubsystemProvider<XRSessionSubsystem>
    {
        // ... 中略 ...
    }
}

XRSessionSubsystem の内部クラスとして Provider が定義されています。このようにして、サブクラスの内部で Provider を定義することを期待しているために WithProvider という名前が与えられてるのだと思います。

サブクラスの実装詳細

次は実際にサブクラスの実装の詳細を見ていきましょう。ここでは、冒頭で紹介したリポジトリのものを引用させていただきました。ここから、どうやって自作のサブシステムを作っていけばいいかが見えてきます。まずはコード全文を見てみましょう。

ちなみに以下に公開されています。

github.com

using System;
using UnityEngine.Scripting;
using UnityEngine.XR.ARSubsystems;

namespace UnityEngine.XR.Mock
{
    [Preserve]
    public sealed class UnityXRMockSessionSubsystem : XRSessionSubsystem
    {
        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        internal static void Register()
        {
            XRSessionSubsystemDescriptor.RegisterDescriptor(new XRSessionSubsystemDescriptor.Cinfo
            {
                id = typeof(UnityXRMockSessionSubsystem).FullName,
                providerType = typeof(MockProvider),
                subsystemTypeOverride = typeof(UnityXRMockSessionSubsystem),
                supportsInstall = false,
                supportsMatchFrameRate = false
            });
        }

        private class MockProvider : Provider
        {
            private TrackingState? prevTrackingState;
            private Guid m_sessionId;

            [Preserve]
            public MockProvider()
            {
                this.m_sessionId = Guid.NewGuid();
            }

            public override Guid sessionId => this.m_sessionId;

            public override Feature currentTrackingMode => Feature.AnyTrackingMode;

            public override int frameRate => Mathf.RoundToInt(1.0f / Time.deltaTime);

            public override IntPtr nativePtr => IntPtr.Zero;

            public override Feature requestedFeatures
                => Feature.AnyTrackingMode
                | Feature.AnyCamera
                | Feature.AnyLightEstimation
                | Feature.EnvironmentDepth
                | Feature.EnvironmentProbes
                | Feature.MeshClassification
                | Feature.PlaneTracking
                | Feature.PointCloud;

            public override Feature requestedTrackingMode
            {
                get => Feature.AnyTrackingMode;
                set { }
            }

            public override TrackingState trackingState => SessionApi.trackingState;

            public override NotTrackingReason notTrackingReason => NotTrackingReason.None;

            public override Promise<SessionInstallationStatus> InstallAsync() => new SessionInstallationPromise();

            public override Promise<SessionAvailability> GetAvailabilityAsync() => new SessionAvailabilityPromise();

            public override void Start()
            {
                SessionApi.Start();
                base.Start();
            }

            public override void Stop()
            {
                SessionApi.Stop();
                base.Stop();
            }

            public override void Destroy()
            {
                SessionApi.Reset();
                base.Destroy();
            }

            public override void OnApplicationPause()
            {
                prevTrackingState = SessionApi.trackingState;
                SessionApi.trackingState = TrackingState.None;
                base.OnApplicationPause();
            }

            public override void OnApplicationResume()
            {
                SessionApi.trackingState = prevTrackingState ?? TrackingState.Tracking;
                base.OnApplicationResume();
            }
        }

        private class SessionInstallationPromise : Promise<SessionInstallationStatus>
        {
            public SessionInstallationPromise()
            {
                this.Resolve(SessionInstallationStatus.Success);
            }

            public override bool keepWaiting => false;

            protected override void OnKeepWaiting() { }
        }

        private class SessionAvailabilityPromise : Promise<SessionAvailability>
        {
            public SessionAvailabilityPromise()
            {
                this.Resolve(SessionAvailability.Supported | SessionAvailability.Installed);
            }

            public override bool keepWaiting => false;

            protected override void OnKeepWaiting() { }
        }
    }
}

コードはとてもシンプルですね。概観するとおおよそ以下のような感じでしょう。

  • XRSessionSubsystem を継承する
  • RuntimeInitializeOnLoadMethodAttribute を利用してサブシステムの登録を行う
  • 内部クラスで Provider クラスを定義する
  • Provider クラスのベースクラスで期待されている機能を実装する
  • 実際の機能の提供はさらに別クラスの SessionApi クラスが担当している

という感じでしょうか。

ここから分かるのは、機能提供のインターフェースとしての Provider をサブシステムが提供し、その具体的な実装をAPIという形で利用しているという点です。参考にしたものはモック用のものなのですべてC#で実装されていますが、これがARCoreやARKitの場合はネイティブ実装を呼び出す、名前通りAPIとして振る舞うクラスが後ろにいると考えるとイメージしやすいでしょう。

SessionApi実装はごくごくシンプルです。コードも短いので見てみましょう。

using UnityEngine.XR.ARSubsystems;

public static class SessionApi
{
    public static TrackingState trackingState { get; set; } = TrackingState.None;

    public static void Start()
    {
        trackingState = TrackingState.Tracking;
    }

    public static void Stop()
    {
        trackingState = TrackingState.None;
    }

    public static void Reset()
    {
        trackingState = TrackingState.None;
    }
}

たんに enum の値を変更しているのみですね。実際にデバイス依存の機能を実装する場合は、こうした形でネイティブ呼び出しをすればいいでしょう。あるいは、例えば、開発効率を爆上げしてくれる ARFoundation Remote というアセットがありますが、こうした機能を実装したい場合は、デバイスから送られてくるデータを流す機能を提供すれば似た機能が実装できるでしょう。

assetstore.unity.com

なんとなく、サブシステムがどう実装されているか見えてきたと思います。

これ以外のサブクラス(例えば XRCameraSubsystem など)もほぼ同様の構成になっています。ひとつサブクラスの仕組みを知ってしまえば他のサブクラスの実装は応用で実装することができると思います。

ということで実装については以上です。最後に、これらをどう使っているかを確認して全体像把握を完成させましょう。

サブシステムの利用

前段までで、サブシステムがどう定義され、どう登録され、どうLoadされるのかについて見てきました。最後に見ていくのは、実際にこれらを利用する部分についてです。興味深いことに、登録したサブクラスの利用はジェネリクスによって実現されています。

利用するためのベースクラス SubsystemLifecycleManager

ここではよく見る ARSession クラスを題材にして見ていきましょう。ARFoundationを利用しているとシーンに配置するあれです。 ARSessionSubsystemLifecycleManager を継承して作られています。そしてこの SubsystemLifecycleManager がまさにサブシステムを利用するための機能を提供してくれているクラスとなります。

サブクラスはいわゆるDIされる形で利用されています。DIの仕組みとしてジェネリクスを利用しているのが興味深いところでしょう。 SubsystemLifecycleManager のクラス宣言部分を見てみると以下のようになっています。

public class SubsystemLifecycleManager<TSubsystem, TSubsystemDescriptor, TProvider> : MonoBehaviour
    where TSubsystem : SubsystemWithProvider<TSubsystem, TSubsystemDescriptor, TProvider>, new()
    where TSubsystemDescriptor : SubsystemDescriptorWithProvider<TSubsystem, TProvider>
    where TProvider : SubsystemProvider<TSubsystem>
{
    // ... 略 ...
}

今まで見てきたクラスが期待されているのが分かりますね。具体的には SubsystemWithProvider, SubsystemDescriptorWithProvider, SubsystemProvider の3つです。

続いて ARSession の宣言部分を見てみましょう。

public sealed class ARSession :
    SubsystemLifecycleManager<XRSessionSubsystem, XRSessionSubsystemDescriptor, XRSessionSubsystem.Provider>
{
    // ... 略 ...
}

まさに、今まで解説してきたクラス、つまり XRSessionSubsystem, XRSessionSubsystemDescriptor, XRSessionSubsystem.Provider が指定されていますね。この指定によって、利用したいサブクラスおよびプロバイダを呼び出すことができるようになっているわけです。そしてインスタンス化されている実際のクラスはDescriptorによって生成されているため、ベースクラス型を指定するだけで実クラスの詳細を気にすることなく利用することができるわけです。

では肝心の、サブクラスのインスタンスを取得している部分を見てみましょう。

protected TSubsystem GetActiveSubsystemInstance()
{
    TSubsystem activeSubsystem = null;

    // Query the currently active loader for the created subsystem, if one exists.
    if (XRGeneralSettings.Instance != null && XRGeneralSettings.Instance.Manager != null)
    {
        XRLoader loader = XRGeneralSettings.Instance.Manager.activeLoader;
        if (loader != null)
            activeSubsystem = loader.GetLoadedSubsystem<TSubsystem>();
    }

    if (activeSubsystem == null)
        Debug.LogWarningFormat($"No active {typeof(TSubsystem).FullName} is available. Please ensure that a " +
                               "valid loader configuration exists in the XR project settings.");

    return activeSubsystem;
}

先に説明したLoaderの GetLoadedSubsystem メソッドからインスタンスを取得しているのが分かります。このメソッドはヘルパークラスである XRLoaderHelper に実装されています。合わせてそれも確認してみましょう。

public override T GetLoadedSubsystem<T>()
{
    Type subsystemType = typeof(T);
    ISubsystem subsystem;
    m_SubsystemInstanceMap.TryGetValue(subsystemType, out subsystem);
    return subsystem as T;
}

前述の CreateSubsystem で生成されたインスタンスを取り出して返しているのが分かります。該当メソッドの実装は以下のようになっています。

protected void CreateSubsystem<TDescriptor, TSubsystem>(List<TDescriptor> descriptors, string id)
    where TDescriptor : ISubsystemDescriptor
    where TSubsystem : ISubsystem
{
    if (descriptors == null)
        throw new ArgumentNullException("descriptors");

    SubsystemManager.GetSubsystemDescriptors<TDescriptor>(descriptors);

    if (descriptors.Count > 0)
    {
        foreach (var descriptor in descriptors)
        {
            ISubsystem subsys = null;
            if (String.Compare(descriptor.id, id, true) == 0)
            {
                subsys = descriptor.Create();
            }
            if (subsys != null)
            {
                m_SubsystemInstanceMap[typeof(TSubsystem)] = subsys;
                break;
            }
        }
    }
}

Descriptorによってサブクラスが生成されているのが分かります。ちなみに生成箇所をもう一度見てみると以下のようになっています。

CreateSubsystem<XRSessionSubsystemDescriptor, XRSessionSubsystem>(s_SessionSubsystemDescriptors, typeof(UnityXRMockSessionSubsystem).FullName);

ジェネリクスとして指定しているのはベースクラスである XRSessionSubsystem ですが、実際に生成されるのは UnityXRMockSessionSubsystem です。Descriptorはどこからくるか? そう、 UnityXRMockSessionSubsystem の Registerメソッドによって自身を生成するDescriptorが指定されているのでしたね。こうしてすべての機能がつながりました。

最後に

イチから理解するにはやや規模の大きいシステムとなっていますが、理解してしまえばあまりむずかしいところはありません。ジェネリクスの使い方なども面白く、とても学びのあるコードリーディングとなりました。

特に、シーンロード前などにシステムがセットアップされているのは、独自でシステムを構築する際にはとても参考になりそうな実装になっていました。普段なにげなく使っているARFoundationですが、裏では結構色々とがんばってくれていたのですね。裏を知ると、ちょっとしたカスタマイズやモックのような新機能を作って開発を加速することもできるようになるので、やはりブラックボックスをなくすことはとても重要だと思います。

XR Plug-in Managementの記事はあまり見かけないので、なにかの参考になれば幸いです。

V8 エンジンを Unity Android アプリ上で動かす(V8 ビルド編)

概要

Google Chrome や Node.js で使われている JavaScript エンジンである V8 エンジンを、Unity のアプリ上で動かすためのあれこれをまとめていきたいと思います。長くなってしまうのでビルド編と使用編に分けて書きます。今回はビルド編です。V8 をどうやって Unity と連携させたかについては次回書きます。

これを利用して簡単なプロトタイプを作ってみた動画を Twitter に投稿しています。

こちらは Unity Editor 上で JavaScript を書いてそれを Cube の動きに適用している例です。

こちらは Android の実機上で同じことをやっている例です。Android 実機でも現状のかんたんなサンプルでは 60FPS 出ているので、処理負荷的には問題なさそうです。



開発環境

  • Ubntsu 18.04.5 on WSL

現状、Android 向けのライブラリのビルドは Windows は対応していないようです。また、依存関係が強く、自分の Mac の環境ではインテルでも M1 でもどちらもビルドができませんでした。(かなり調べましたが、エラーに次ぐエラーで解決できず・・・)

なので今回は WSL を利用し、Ubuntsu 18.04.5 上でビルドを行いました。

V8 をビルドする準備

V8 は様々なツール群を連携させながらビルドする必要があります。公式サイトに手順が載っているものの、環境依存が強く、実際にビルドして利用できる形にするまでかなり苦戦しました。後半に、ビルドを試していく中で遭遇した問題のトラブルシューティングを掲載しています。

▼ V8 の公式サイト v8.dev

▼ V8 のビルド手順 v8.dev

ツールをインストール

V8 をビルドするためにはツールをインストールする必要があります。公式ドキュメントに沿ってセットアップしていきます。

depot_tools をインストール

まずはじめに depot_tools をインストールします。これは V8 エンジンのビルドに必要なファイルのダウンロードや依存関係の解決などをしてくれるツールです。

まずは Git から clone します。

$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

clone したツールを PATH に追加します。

$ export PATH=/path/to/depot_tools:$PATH

depot_tools を update

インストールが終わったら最初に depot_tools を update しておきます。以下のコマンドで自動的に更新されます。

$ gclient

V8 用のディレクトリを作成する

次に、V8 エンジンのソースコードを取ってくるためディレクトリを作成します。

$ mkdir ~/v8
$ cd ~/v8

V8 のソースコードを fetch する

depot_tools を PATH に追加してあれば fetch コマンドが使えるようになっているため、以下のようにして V8 エンジンのソースコードを取得することができます。

$ fetch v8
$ cd v8

ターゲット OS を設定する

今回は Android 向けにビルドを行います。そのためツールに NDK などを含める必要があるので設定ファイルを更新します。

fetch コマンドを実行したディレクトリに .gclient ファイルがあるのでこれに target_os = ['android'] を追記します。以下は実際に追記した例です。

# solutions はデフォルトで記載されている
solutions = [
  {
    "name": "v8",
    "url": "https://chromium.googlesource.com/v8/v8.git",
    "deps_file": "DEPS",
    "managed": False,
    "custom_deps": {},
  },

# 以下を追記
target_os = ['android']

Android 用ツールを更新する

上記のターゲットを追加した状態で以下のコマンドを実行すると、Android 向けにビルドするためのツールがインストールされます。

$ gclient sync

これでビルドの準備が整いました。

ビルドする

必要なファイルを生成し、ビルドを実行します。

ファイルを生成する

以下のツールを使うことで必要なファイルが生成されます。

$ tools/dev/v8gen.py arm.release

※1 Python2 系が必要なため、環境によってはインストールされていないかもしれません。自分は Linux 向けの Miniconda を利用して環境を作成し、ビルドを行いました。

※2 list オプションを指定すると生成できる種類がリストされます。

$ tools/dev/v8gen.py list

ビルドの設定を調整する

ビルドの設定は以下のコマンドから変更することができます。

$ gn args out.gn/arm.release

上記を実行するとテキストエディタが起動します。自分の環境では以下の設定にすることでエラーなくビルドできました。

is_debug = false
target_cpu = "arm64"
v8_target_cpu = "arm64"
target_os = "android"
v8_monolithic = true
v8_use_external_startup_data = false
use_custom_libcxx = false

マングリングの問題

留意点があります。最後に記載している use_custom_libcxx = false ですが、これを指定しないとビルドツールに同梱されている clang コンパイラが利用され、Android Studio 側に持っていった際に undefined symbol のエラーが表示されてしまうので注意してください。

これはコンパイラの仕様で、コンパイル時に関数などのシンボルをどう処理するかに依存します。Android Studio で利用しているコンパイラおよびリンカが異なるためにこうした問題が発生してしまいます。そのため、同梱されている clang を利用しないことでコンパイラの処理内容が一致し、エラーが出なくなるわけです。いわゆるマングリングに関する問題です。

ビルドを実行する

ここまで準備ができたら以下のコマンドからビルドを実行します。

$ ninja -C out.gn/arm.release

数千ファイルにおよぶコンパイルが走るので、終わるまで少し待ちます。

ビルドが終わると out.gn/arm.release/obj ディレクトリに libv8_monolith.a というライブラリファイルが生成されています。これが V8 エンジンの機能をひとつにまとめたスタティックライブラリです。これを Android Studio 側にインポートすることで V8 エンジンの機能を C++ から利用することができるようになります。

と、さくっと書きましたがビルドが成功するまでに色々なエラーにぶつかりました。以下は自分が遭遇したエラーのトラブルシューティングです。

トラブルシューティング

自分がビルドを行う過程でいくつか遭遇した問題のトラブルシューティングを記載しておきます。(Windows 向け、Mac 向け、Mac 上でのビルドなどなど、今回のビルドとは関係ない部分でのトラブルシュートもありますが、なにかしらで役に立つと思うのでまとめておきます)

No such file or directory

ビルド中、いくつかの必要なファイルがなく No such file or directory に類するエラーが発生しました。遭遇したエラーについてまとめておきます。

見つからないファイルを NDK フォルダから探す

これらの問題への対処は以下の記事を元に対処しました。

www.jianshu.com

この問題の原因は、depot_tools に同梱されている NDK には含まれていないファイル があったためでした。なので自前で NDK をダウンロードしてきて、必要なファイルを depot_tools 同梱の NDK フォルダに適宜コピーする、という方法で対処しました。

depot_tools で利用されている NDK のバージョンは以下で確認できます。

$ cat <v8_directory>/third_party/android_ndk/source.properties
Pkg.Desc = Android NDK
Pkg.Revision = 23.0.7599858

自身でダウンロードした NDK の中に該当ファイルを見つけたら、以下のような形で third_party ディレクトリ側にコピーします。

cp -rv 23.0.7599858/toolchains/llvm/prebuilt/darwin-x86_64/ /path/to/v8/v8/third_party/android_ndk/toolchains/llvm/prebuilt/darwin-x86_64/

ちなみに該当ファイルを見つける場合は find コマンドを利用するとすぐに見つかります。

# ファイル検索の例
$ find . -name features.h                                                                                                                                                                                                        
./toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/features.h

以下、遭遇した対象ファイル/エラーとその対策をリストしておきます。

  • fatal error: ‘features.h’ file not found
cp -rv ~/Library/Android/sdk/ndk/23.0.7599858/toolchains/llvm/prebuilt/darwin-x86_64 /path/to/v8/v8/third_party/android_ndk/toolchains/llvm/prebuilt/darwin-x86_64
cp -rv ~/Library/Android/sdk/ndk/23.0.7599858/toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.5/lib/linux ~/MyDesktop/GitRepo/v8-repo/v8/third_party/llvm-build/Release+Asserts/lib/clang/15.0.0/lib/linux
  • FileNotFoundError: [Errno 2] No such file or directory: 'llvm-strip'
cp -rv ~/Library/Android/sdk/ndk/23.0.7599858/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-strip ~/MyDesktop/GitRepo/v8-repo/v8/third_party/llvm-build/Release+Asserts/bin/llvm-strip
  • ld.lld: error: libclang_rt.builtins-aarch64-android.a: No such file or directory
find ./ -name libclang_rt.builtins-aarch64-android.a
.//toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.5/lib/linux/libclang_rt.builtins-aarch64-android.a
cp ~/Library/Android/sdk/ndk/23.0.7599858/toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.5/lib/linux/libclang_rt.builtins-aarch64-android.a /path/to/v8/v8/third_party/llvm-build/Release+Asserts/bin/

pkg_config でエラー

Python スクリプトを実行中、 pkg_config 関連でエラーが出ました。これは単純に対象のパッケージがインストールされていなかったのが問題なので、以下のようにしてインストールすることで回避できます。

sudo apt-get update && sudo apt-get install pkg-config

'GLIBCXX_3.4.26' not found

エラー全文は以下です。

/usr/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.26' not found (required by ./clang_x64_v8_arm64/torque)

以下の記事を参考にしました。

https://scrapbox.io/tamago324vim/%2Fusr%2Flib%2Fx86_64-linux-gnu%2Flibstdc++.so.6:_version_%60GLIBCXX_3.4.26'_not_found_%E3%81%A3%E3%81%A6%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AB%E3%81%AA%E3%82%8Bscrapbox.io

strings コマンドで確認してみると確かにバージョンが足らない。( GLIBCXX_3.4.25 までしかない)

$ strings /usr/lib/x86_64-linux-gnu/libstdc++.so.6 | grep GLIBCXX_3
GLIBCXX_3.4
GLIBCXX_3.4.1
# 中略
GLIBCXX_3.4.24
GLIBCXX_3.4.25

以下のコマンドで追加のバージョンをインストールしました。

$ sudo apt-get install software-properties-common
$ sudo add-apt-repository ppa:ubuntu-toolchain-r/test
$ sudo apt install gcc-10 g++-10 -y

インストール後、改めて確認するとバージョンが増えていました。

edom18:~/GitRepo/v8-repo/v8$ strings /usr/lib/x86_64-linux-gnu/libstdc++.so.6 | grep GLIBCXX_3
GLIBCXX_3.4
GLIBCXX_3.4.1
# 中略
GLIBCXX_3.4.24
GLIBCXX_3.4.25
GLIBCXX_3.4.26
GLIBCXX_3.4.27
GLIBCXX_3.4.28
GLIBCXX_3.4.29

最後に

C/C++ プロジェクトのビルドは依存関係が強く、毎回骨が折れます。今回はなんとか Android 向けのライブラリがビルドできてよかったです。このあとはさらに MacOSiOS 向けにライブラリをビルドし、Unity アプリ内で JavaScript が使える環境を作っていこうと思っています。

次回は V8 を C++ プロジェクトから利用し、さらにそれをライブラリ化して Unity C# から利用する方法について書きたいと思います。

C# Job System + Burst compilerを使ってPNG画像の展開を最適化してみる

概要

前回の記事前々回の記事PNG画像について書きました。

前回の記事で、最適化について書けたら書きますと書いていたのですが、C# Job SystemとBurst compilerを利用して最適化してみたのでそれをメモがてら書いておきたいと思います。

ちなみに、UnityのAPIである Texture2D.LoadImage(byte[] data); に比べると解凍部分の時間以外は大体同じくらいの時間で展開できているみたいです。(ただ、解凍処理が重くて合計すると倍くらいの時間かかってしまっていますが・・)

PNG画像展開の実装についてはGitHubにあげてあるので、実際に動くものを見たい方はそちらをご覧ください。

github.com

実装したものをAndroidの実機で動かしたデモです↓



C# Job Systemとは

C# Job System自体の解説記事ではないのでここでは簡単な実装方法の解説にとどめます。詳細については以下の記事がとても分かりやすくまとめてくれているのでそちらをご覧ください。 tsubakit1.hateblo.jp

概要についてドキュメントから引用すると、

Unity C# Job System を利用すると、Unity とうまく相互作用する マルチスレッドコード を書くことができ、正しいコードを書くことを容易にします。

マルチスレッドでコードを書くと、高いパフォーマンスを得ることができます。これらには、フレームレートの大幅な向上が含まれます。C#ジョブで Burst コンパイラーを使用すると、改良された コード生成 (英語)の品質が提供され、モバイルデバイスのバッテリー消費を大幅に削減します。

C# Job System の本質的な性質は Unity が内部で使用するもの (Unity のネイティブジョブシステム) との統合性です。ユーザーが作成したコードと Unity は ワーカースレッド を共有します。この連携により、CPU コア より多くのスレッドを作成すること (CPU リソースの競合の原因となります) を回避できます。

マルチスレッドプログラミングでは、競合の問題やスレッドを立ち上げることのコスト、スレッドの立ち上げすぎに寄るコンテキストスイッチのコストなどが問題になることがあります。しかしC# Job Systemを利用すると、Unityが起動しているスレッドの空いている時間を使って効率よく処理を行うことができるため低コストかつ安全にコードを書くことを可能にしてくれます。

加えて、後述するBurst Compilerを併用することでさらに高速化を望むことができます。

ただ、メインスレッド外で動作するためUnity APIを使うことができないという制約はそのままです。ただし Transform に限定して、それを操作する方法が提供されています。(今回は主題ではないので詳細は割愛します)

実装方法

C# Job Systemを利用するためには必要なインターフェースを実装し、それをスレッドで実行されるようにスケジュールする必要があります。

簡単な利用方法について以下の記事を参考にさせていただきました。 gametukurikata.com

IJobを実装する

まず1番シンプルな方法は IJob インターフェースを実装することです。

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

// 必ず struct にする必要がある
public struct AnyJob : IJob
{
    // 計算に利用する値
    public float value;

    // 結果を返すNativeArray
    public NativeArray<float> result;

    public void Execute()
    {
        for (int i = 0; i < 100; ++i)
        {
            result[0] += value;
        }
    }
}

※ Burst compilerで利用する場合、 class は使えないため必ず struct で定義する必要があります。

ここで、なぜ NativeArray を、しかも1要素のものを利用しているかというと、これはジョブシステムの安全性に起因するものです。

参考にした記事から引用すると、

C# Job Systemの安全機能によってジョブの結果が共有出来ない為、Nativeコンテナと呼ばれる共有メモリを使って結果を保存します。

と書かれています。そしてこのNativeコンテナはいくつか種類があり、以下のようなものが標準で用意されています。

  • NativeArray
  • NativeList
  • NativeHashMap
  • NativeMultiHashMap
  • NativeQueue

また特殊ケースに対応するため、コンテナは自身で定義、作成することもできるようになっています。

並列処理用のJob(IJobParallelFor)を実装する

上記のジョブはひとつのタスクをワーカースレッドで実行するものでした。スレッドで処理したいものの中には並列実行したいものも多数存在します。(それこそ今回はまさにこちらを利用しました)

その場合は IJobParallelFor インターフェースを実装します。基本的な使い方は IJob と変わりありませんが、実装すべきメソッドが少しだけ異なります。

// IJobParallelForのメソッド
void Execute(int index);

違いは引数に int 型の index を受け取るところです。並列実行されるため、そのジョブが「今何番目の位置の処理をすべきか」を index で知ることができるわけですね。

なのでデータを並列化可能な状態で用意したあと、この index を頼りに必要なデータを取り出し、計算を行って結果を返す処理を書いていくことになります。

Jobを実行する

Jobが実装できたら、次はこれを実行する方法を見てみましょう。

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class JobExecutor : MonoBehaviour
{
    private void Start()
    {
        NativeArray<float> resultArray = new NativeArray<float>(1, Allocator.TempJob);

        AnyJob job = new AnyJob
        {
            value = 0.5f,
            result = resultArray,
        };

        // ジョブをスケジュールすると
        // ジョブシステムによって空いているワーカースレッドで実行される
        JobHandle handle = job.Schedule();

        // ジョブの完了を待つ
        handle.Complete();

        // 結果を取り出す
        float result = resultArray[0];

        Debug.Log($"Result: {reuslt}");

        resultArray.Dispose();
    }
}

大事な点としては、結果を受け取るための NativeArray<T> を定義し、メモリをアンマネージド領域に確保します。そしてそれをジョブに設定しスケジュールします。

ジョブはそのまま実行するのではなく、空いているワーカースレッドで実行されるようにスケジュールする必要があります。

なお、ジョブに対して入力が必要な場合は同様に NativeArray<T> を利用してデータを渡す必要があります。

Jobを直列につなげる

場合によっては「このジョブが終わったあとに次のジョブを実行する」という形で、前のジョブに依存するような処理もあります。(今回の実装でも、並列化可能な部分は並列化したジョブで処理を行い、その後の処理はこの並列化した処理に依存した形で進んでいきます)

直列化させるのは簡単で、スケジュールする際に 前に終わっていたほしいジョブのハンドル を引数に渡します。

PreviousJob previousJob = new PreviousJob { ... };
NextJob nextJob = new NextJob { ... };

JobHandle previousHandle = preivousJob.Schedule();
JobHandle nextHandle = nextJob.Schedule(previousHandle);

Burst Compilerとは

Unity Blogから引用すると以下のように書かれています。

Burst は、新しいデータ指向技術スタック(DOTS)とUnity Job System を使って作成された、Unity プロジェクトのパフォーマンスを向上させるために使用できる事前コンパイラー技術です。Burst は、高性能 C#(HPC#)として知られる C# 言語のサブセットをコンパイルすることで動作し、LLVM コンパイラフレームワークの上に構築された高度な最適化を展開することで、デバイスのパワーを効率的に利用します。

また、ドキュメントから引用すると、

Burst is a compiler, it translates from IL/.NET bytecode to highly optimized native code using LLVM. It is released as a unity package and integrated into Unity using the Unity Package Manager.

と書かれています。以下の動画でも言及がありますが、通常はC#はIL(Intermediate Language)に変換されます。しかし、Burst CompilerではIR(Intermediate Representation)に変換し、LLVMでさらに機械語に変換される、という手順を踏みます。 (動画ではLLVMを「人類の英知の結晶」と呼んでいましたw)

ちょっとまだしっかりと理解したわけではないですが、大きく最適化を施したコードが生成される、と思っておけばいいでしょう。ただ、無償でそうした最適化が手に入るわけではなく、それなりの制約を課したコードを書く必要がある点に注意が必要です。

www.youtube.com

Burst Compilerを適用するには BurstCompile アトリビュートを付与するだけです。(後述のコードを参照ください)

なお、2022.02.25時点で Package Managerから検索してインストール ができないようです。インストールについてはこちらの記事を参考にさせていただきました。

ざっくりとした解説は以上です。以下から、実際のコードを元に解説をしていきます。

並列化して高速化

前回の記事PNGデータの展開処理について書きました。しかし前回の実装ではすべてのピクセルを順次処理していました。しかし、展開の仕組みを理解すると、Filter Type 1の場合、つまり左のピクセルにのみ依存している場合はその行だけの処理で完結します。言い換えるとすべての行は並列に処理できるということです。

ということで、Filter Type 1の行を抜き出してそこを並列化してみます。並列化には前述したC# Job Systemを採用しました。(Burst compilerによる高速化も見込めるかなと思ったので)

Filter Type 1の行を抜き出す

まずは該当の行を抜き出します。

// Filter Type 1の行とそれ以外の行を抜き出す
LineInfo info = PngParser.ExtractLines(data, _metaData);

// -----------------

// 抜き出し処理
// シンプルに行の頭のFilter Typeを判定しているだけ
public static LineInfo ExtractLines(byte[] data, PngMetaData metaData)
{
    List<int> type1 = new List<int>();
    List<int> other = new List<int>();

    for (int h = 0; h < metaData.height; ++h)
    {
        int idx = metaData.rowSize * h;
        byte filterType = data[idx];

        if (filterType == 1)
        {
            type1.Add(h);
        }
        else
        {
            other.Add(h);
        }
    }

    return new LineInfo
    {
        filterType1 = type1.ToArray(),
        otherType = other.ToArray(),
    };
}

Filter Type 1の行とそれ以外を抜き出したら、それぞれを分けてジョブを設定しスケジュールします。

LineInfo info = PngParser.ExtractLines(data, _metaData);

_type1Indices = new NativeArray<int>(info.filterType1, Allocator.Persistent);
_otherIndices = new NativeArray<int>(info.otherType, Allocator.Persistent);
_dataArray = new NativeArray<byte>(data, Allocator.Persistent);
_pixelArray = new NativeArray<Pixel32>(_metaData.width * _metaData.height, Allocator.Persistent);

ExpandType1Job type1Job = new ExpandType1Job
{
    indices = _type1Indices,
    data = _dataArray,
    pixels = _pixelArray,
    metaData = _metaData,
};

ExpandJob job = new ExpandJob
{
    indices = _otherIndices,
    data = _dataArray,
    pixels = _pixelArray,
    metaData = _metaData,
};

JobHandle type1JobHandle = type1Job.Schedule(info.filterType1.Length, 32);
_jobHandle = job.Schedule(type1JobHandle);

実際に、左のピクセルだけに依存する行を処理するジョブの実装を以下に示します。

using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;

[BurstCompile]
public struct ExpandType1Job : IJobParallelFor
{
    // Filter type 1の行のIndexを格納しているNativeArray。
    [ReadOnly] public NativeArray<int> indices;
    
    // PNGの生データ
    public NativeArray<byte> data;

    // 計算結果を格納するピクセル配列
    public NativeArray<Pixel32> pixels;

    // PNG画像のメタデータ
    public PngMetaData metaData;

    // 各行ごとに並列処理する
    public void Execute(int index)
    {
        int y = indices[index];

        int idx = metaData.rowSize * y;
        int startIndex = idx + 1;

        if (data.Length < startIndex + (metaData.width * metaData.stride))
        {
            throw new IndexOutOfRangeException("Index out of range.");
        }

        Expand(startIndex, y);
    }

    private unsafe void Expand(int startIndex, int y)
    {
        Pixel32* pixelPtr = (Pixel32*)pixels.GetUnsafePtr();
        pixelPtr += (metaData.width * (metaData.height - 1 - y));

        byte* ptr = (byte*)data.GetUnsafePtr();
        ptr += startIndex;

        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)ptr;

            left = Pixel32.CalculateFloor(current, left);

            ptr += metaData.stride;

            *pixelPtr = left;
            ++pixelPtr;
        }
    }
}

前回の実装のFilter Type 1の場合の処理を並列処理するジョブで実装した例です。

基本的な処理は前回のものとほとんど変わりありませんが、データの取得とその格納部分で、行のIndexを利用して処理をしている点が異なります。

Filter Type 1の行は他の行に依存しないため並列に処理しても競合は起きません。なので今回はここを IJobParallelFor の並列ジョブで実装しました。データを見てみると、だいたいの場合において半分くらいはこのタイプのようなので(自分のチェックした観測範囲内では)、半分のデータを並列処理して高速化することが見込めます。

実際、前回の実装との負荷を比較してみると2~3倍くらいには速くなっていました。

後続の処理もIJobで実装

前述のように、左ピクセルのみに依存する行については並列に処理することができます。一方、それ以外のFilter Typeの場合は上の行にも依存するため逐一計算していく必要があります。(実際には、依存する上の行だけを抜き出してそれをループ処理することで並列化は可能だと思いますが、今回は Filter Type 1 とそれ以外という形で実装しました)

以下にその実装のコードを示します。

using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;

[BurstCompile]
public struct ExpandJob : IJob
{
    [ReadOnly] public NativeArray<int> indices;
    
    public NativeArray<byte> data;
    public NativeArray<Pixel32> pixels;

    public PngMetaData metaData;

    public void Execute()
    {
        for (int i = 0; i < indices.Length; ++i)
        {
            int y = indices[i];

            int idx = metaData.rowSize * y;
            int startIndex = idx + 1;

            if (data.Length < startIndex + (metaData.width * metaData.stride))
            {
                throw new IndexOutOfRangeException("Index out of range.");
            }

            byte filterType = data[idx];

            switch (filterType)
            {
                case 0:
                    ExpandType0(startIndex, y);
                    break;

                // case 1:
                //     ExpandType1(data, startIndex, stride, h, pixels, metaData);
                //     break;

                case 2:
                    ExpandType2(startIndex, y);
                    break;

                case 3:
                    ExpandType3(startIndex, y);
                    break;

                case 4:
                    ExpandType4(startIndex, y);
                    break;
            }
        }
    }
    
    private unsafe void ExpandType0(int startIndex, int y)
    {
        Pixel32* pixelPtr = (Pixel32*)pixels.GetUnsafePtr();
        pixelPtr += (metaData.width * (metaData.height - 1 - y));

        byte* ptr = (byte*)data.GetUnsafePtr();
        ptr += startIndex;

        Pixel32 current = default;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)ptr;

            ptr += metaData.stride;

            *pixelPtr = current;
            ++pixelPtr;
        }
    }

    private unsafe void ExpandType2(int startIndex, int y)
    {
        Pixel32* pixelPtr = (Pixel32*)pixels.GetUnsafePtr();
        pixelPtr += (metaData.width * (metaData.height - 1 - y));

        byte* ptr = (byte*)data.GetUnsafePtr();
        ptr += startIndex;

        Pixel32 up = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)ptr;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixelPtr + upStride);
            }

            up = Pixel32.CalculateFloor(current, up);

            ptr += metaData.stride;

            *pixelPtr = up;
            ++pixelPtr;
        }
    }

    private unsafe void ExpandType3(int startIndex, int y)
    {
        Pixel32* pixelPtr = (Pixel32*)pixels.GetUnsafePtr();
        pixelPtr += (metaData.width * (metaData.height - 1 - y));

        byte* ptr = (byte*)data.GetUnsafePtr();
        ptr += startIndex;

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)ptr;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixelPtr + upStride);
            }

            left = Pixel32.CalculateAverage(current, left, up);

            ptr += metaData.stride;

            *pixelPtr = left;
            ++pixelPtr;
        }
    }

    private unsafe void ExpandType4(int startIndex, int y)
    {
        Pixel32* pixelPtr = (Pixel32*)pixels.GetUnsafePtr();
        pixelPtr += (metaData.width * (metaData.height - 1 - y));

        byte* ptr = (byte*)data.GetUnsafePtr();
        ptr += startIndex;

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 leftUp = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)ptr;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixelPtr + upStride);
            }

            if (y == 0 || x == 0)
            {
                leftUp = Pixel32.Zero;
            }
            else
            {
                *(uint*)&leftUp = *(uint*)(pixelPtr + upStride - 1);
            }

            left = Pixel32.CalculatePaeth(left, up, leftUp, current);

            ptr += metaData.stride;

            *pixelPtr = left;
            ++pixelPtr;
        }
    }
}

実装したジョブを実行する

上記のジョブを実際に実行しているコードは以下になります。

private async void StartJob()
{
    string filePath = PngImageManager.GetSavePath(_urlField.text);

    (PngMetaData metaData, byte[] data) = await Task.Run(() =>
    {
        byte[] rawData = File.ReadAllBytes(filePath);
        return PngParser.Decompress(rawData);
    });
    
    _metaData = metaData;
    LineInfo info = PngParser.ExtractLines(data, _metaData);

    _type1Indices = new NativeArray<int>(info.filterType1, Allocator.Persistent);
    _otherIndices = new NativeArray<int>(info.otherType, Allocator.Persistent);
    _dataArray = new NativeArray<byte>(data, Allocator.Persistent);
    _pixelArray = new NativeArray<Pixel32>(_metaData.width * _metaData.height, Allocator.Persistent);
    
    _stopwatch.Restart();

    ExpandType1Job type1Job = new ExpandType1Job
    {
        indices = _type1Indices,
        data = _dataArray,
        pixels = _pixelArray,
        metaData = _metaData,
    };

    ExpandJob job = new ExpandJob
    {
        indices = _otherIndices,
        data = _dataArray,
        pixels = _pixelArray,
        metaData = _metaData,
    };

    JobHandle type1JobHandle = type1Job.Schedule(info.filterType1.Length, 32);
    _jobHandle = job.Schedule(type1JobHandle);

    _started = true;
    
    ShowTexture();
}

private unsafe void ShowTexture()
{
    // Needs to complete even if it checked `IsCompleted`.
    // This just avoids an error.
    _jobHandle.Complete();

    IntPtr pointer = (IntPtr)_pixelArray.GetUnsafePtr();

    Texture2D texture = new Texture2D(_metaData.width, _metaData.height, TextureFormat.RGBA32, false);

    texture.LoadRawTextureData(pointer, _metaData.width * _metaData.height * 4);
    texture.Apply();

    _preview.texture = texture;

    Dispose();

    _started = false;

    _stopwatch.Stop();

    Debug.Log($"Elapsed time: {_stopwatch.ElapsedMilliseconds.ToString()}ms");
}

以下の部分が、Filter Type 1 の行を抜き出し、それぞれのインデックスバッファを生成している箇所です。

LineInfo info = PngParser.ExtractLines(data, _metaData);

_type1Indices = new NativeArray<int>(info.filterType1, Allocator.Persistent);
_otherIndices = new NativeArray<int>(info.otherType, Allocator.Persistent);

しれっと「インデックスバッファ」と書きましたが、CGのレンダリングでインデックスバッファと頂点バッファを生成しているくだりに似ているのでそう表現しました。ここで得られた行番号( _type1Indices )のリストが、並列化可能な行となります。

そして _otherIndices が残りの行となります。あとはこれらのデータと結果を受け取る配列の NativeArray<Pixel32> をアロケートし、それをジョブに渡すことでJob SystemによるPNGの展開処理が完了します。

最後に

今回、初めてしっかりとC# Job SystemとBurst Compilerを使ってみましたが下準備が少し面倒なだけで、安全にマルチスレッドでの処理を書けるのはとてもいいと思いました。うまくすればBurst Compilerによって高速化も望めます。並列化できそうな処理があったら積極的に使っていきたいと思います。

PNGデータを自前で展開してテクスチャ化する

概要

前回の記事PNGデータの構造とテキストチャンクにデータを書き込むことを書きました。

今回はさらに話を進めて、自前でPNGデータを展開しテクスチャ化するまでを書いてみようと思います。またさらに、速度を上げるためにポインタを直に使っています。(それでもUnityのネイティブ実装に比べるとだいぶ遅いですが、すべてを非同期にできるので多少は有用性があるかも)

なお、PNGにはいくつかのカラータイプがありますが、今回はあくまで内容把握が目的なのでαチャンネルありのカラー限定で対応しています。

例によって今回の実装もGitHubに上がっているので、実際の動作・コードを確認したい人はそちらをご覧ください。

github.com



PNGのデータ構造

全体的な仕様は前回の記事を参照ください。ここでは IDAT チャンク、つまり画像データそのものについて書いていきます。

PNGデータは複数の IDAT チャンクから構成される

PNGデータは複数のチャンクデータから成り、実際の画像データとしての部分は IDAT チャンクと呼ばれるチャンクに格納されています。またさらに、このチャンクは複数個ある場合があり、その場合はすべての IDAT チャンクのデータ部を結合したひとつのバイト配列が画像データを表すデータとなります。

前回の記事の画像を引用すると以下のような構成になっています。

ここの IDAT チャンクが(場合によっては)複数個配置されているというわけですね。

PNGデータは圧縮されている

実は IDAT チャンクのデータ部分を結合しただけでは画像として利用できません。というのも、このデータ部分は Deflate 圧縮が施されているのでそれを先に解凍する必要があります。

データを復元する

それを踏まえて、データを取り出している部分のコードを抜粋します。

データを取り出すために以下ふたつの構造体を定義しています。

public struct Chunk
{
    public int length;
    public string chunkType;
    public byte[] chunkData;
    public uint crc;
}

public struct PngMetaData
{
    public int width;
    public int height;
    public byte bitDepth;
    public byte colorType;
    public byte compressionMethod;
    public byte filterMethod;
    public byte interlace;
}

これを利用して展開処理をしている部分を見ていきましょう。

public static (PngMetaData metaData, byte[]) Decompress(byte[] data)
{
    // ヘッダチャンクを取得
    Chunk ihdr = GetHeaderChunk(data);

    // ヘッダチャンクから幅、高さなどのメタデータを取得
    PngMetaData metaData = GetMetaData(ihdr);

    const int metaDataSize = 4 + 4 + 4;

    int index = PngSignatureSize + ihdr.length + metaDataSize;

    List<byte[]> pngData = new List<byte[]>();

    int totalSize = 0;

    // IDATチャンクを検索し見つかったものをすべてリストに追加する
    while (true)
    {
        if (data.Length < index) break;

        Chunk chunk = ParseChunk(data, index);

        if (chunk.chunkType == "IDAT")
        {
            pngData.Add(chunk.chunkData);
            totalSize += chunk.length;
        }

        if (chunk.chunkType == "IEND") break;

        index += chunk.length + metaDataSize;
    }

    // 最初の2byteがマジックバイトがあるため、それをスキップする
    // 参考:https://stackoverflow.com/questions/20850703/cant-inflate-with-c-sharp-using-deflatestream
    int skipCount = 2;

    byte[] pngBytes = new byte[totalSize - skipCount];
    Array.Copy(pngData[0], skipCount, pngBytes, 0, pngData[0].Length - skipCount);

    int pos = pngData[0].Length - skipCount;
    for (int i = 1; i < pngData.Count; ++i)
    {
        byte[] d = pngData[i];
        Array.Copy(d, 0, pngBytes, pos, d.Length);
        pos += d.Length;
    }

    // データ部分をDeflateStreamを使って解凍する
    using MemoryStream memoryStream = new MemoryStream(pngBytes);
    using MemoryStream writeMemoryStream = new MemoryStream();
    using DeflateStream deflateStream = new DeflateStream(memoryStream, CompressionMode.Decompress);

    deflateStream.CopyTo(writeMemoryStream);
    byte[] decompressed = writeMemoryStream.ToArray();

    return (metaData, decompressed);
}

データ内にマジックバイトが含まれているため削除が必要

上記コードのコメントにも記載していますが、最初、データを解凍しようとしたらエラーが出てうまく行きませんでした。色々調べた結果、以下の記事で言及があるように、2バイトのマジックバイトが含まれており、それを取り除いて解凍しないとエラーが出てしまうようです。

If you read my comment you will see that I encountered this problem 18 hours ago and although the answer to the problem is here in your answer it is not directly apparent. In your answer there is the variable set wantRfc1950Header = true and in your input stream the first two bytes are the RFC 1950 magic bytes 78 9c. The System.IO.Compression.DeflateStream expects a raw RFC 1951 stream that has these two bytes omitted. I imagine you should be able to use your initial example if you chop off these first two bytes before feeding it to the inflator.

On the downside it has taken me over 18 hours to find out that I need to remove two bytes of data. On the upside I am much more familiar with the internals of zlib and Huffman coding.

stackoverflow.com

コードにすると以下の部分ですね

// 最初の2byteがマジックバイトがあるため、それをスキップする
// 参考:https://stackoverflow.com/questions/20850703/cant-inflate-with-c-sharp-using-deflatestream
int skipCount = 2;

byte[] pngBytes = new byte[totalSize - skipCount];
Array.Copy(pngData[0], skipCount, pngBytes, 0, pngData[0].Length - skipCount);

フィルタリングを解く

前段まででデータの解凍ができました。いちおうこの時点でもRGBAのデータとして扱えるバイトの並びになっています。しかしPNGDeflate 圧縮が有効に働くように加工されており、そのままだと色がおかしなことになってしまいます。ということで、次はこの加工(フィルタリング)されたデータを復元する展開処理を見ていきます。

なお、展開に関しては以下の記事を参考にさせていただきました。

darkcrowcorvus.hatenablog.jp

記事から引用させてもらうと以下のようにフィルタリングされているデータが格納されています。

PNGファイルに収められる画像データは、zlibによって圧縮される前に、その圧縮効率を上げる目的で フィルタリング という事前処理が施される

PNGイメージをパースする際、zlib解凍を行った後 それを本来の画像データに戻すために、そのデータのフィルタリングを解く必要がある

フィルタリングの種類

フィルタリングにはいくつか種類があります。ざっくり言うと、「どのピクセルを参考にして復元するか」の種別です。以下にその種類と意味をまとめます。

番号 フィルタ名 説明
0 None フィルタなし。そのまま色データとして扱う
1 Sub 隣接する左ピクセルの色との差分
2 Up 隣接する上ピクセルの色との差分
3 Average 左と上のピクセルの平均色との差分
4 Paeth 左、上、左上のピクセルのうち次回出現しそうな色との差分

このフィルタリングが意味するところは、現在処理しているピクセルをどう復元すればいいかを示すものです。

どういう情報になってるのかについては以下のサイトを参考にさせていただきました。

www.webtech.co.jp

説明を引用させていただくと、

例えば、このような10個の数値が並んでいたとします。それぞれの数値は画像の各画素の「色の値」を表わしていると思ってください。

データの意味
これらの数値を、全部覚えなくてはならなかったら、どうしますか? 10個もあると、暗記するのはちょっと大変そうですね。

でも良く見るとこれ、「左から順に、1 ずつ増えている」ことに気がつきます。

だから、こう書き換えてみたらどうでしょう。

加工

数値そのものではなく、差分を取ってその数値に置き換えてみました。また、この数値が「左隣との差分」であるというメモも書き添えます。

数値は10個のまま変わりませんが、急にスッキリして、なんだかとても覚えやすそうになりましたね。

これが「フィルタ」により加工した例です。

これをものすごくざっくりまとめると、

  • 左(や上など)のピクセルの情報を応用してデータを圧縮
  • どういう差分方式かの情報(フィルタタイプ)を1行ごとに追加する

というデータに変換することをフィルタリングと呼んでいるわけですね。

そして上の表にあるように、このフィルタタイプに応じて復元するピクセルの色の計算方法が変わります。

ひとつ例を上げましょう。

まず、以下の画像をPNG化する例を考えます。

これの赤枠に注目して見てみます。

以下に示すように、画像の1行に着目し、それを計算していきます。その際、「どう計算したのか」を示す値を行の先頭に付け加えます。

※ 以下の画像はαなしのRGB24bitの例です。が、計算方法はその他のカラータイプも同様です。

元のデータの並びが上段、それを計算したのが下段です。上記例では左のピクセルからの差分を取ってそれを保持しています。つまり計算自体は右から行うわけですね。そしてFilter type:1と書かれているのが、その行がどのタイプの計算になっているかを示しています。

つまり、画像に対して1行ごとに処理を行い、その処理のタイプを行の頭のに設定します。言い換えるとこれらの手順の逆処理をしていけば元のピクセルデータを復元することができます。(ちなみに元の色が100%再現できます。これが可逆圧縮と言われている所以ですね)

フィルタリングを解く実装

構造およびそれらの意味について見てきました。あとはこれを参考にして実際に展開処理を実装していきます。ということで、展開しているコードを見てみます。

private static Texture2D ParseAsRGBA(byte[] rawData, SynchronizationContext unityContext)
{
    // ファイルから読み込んだ生のデータを、前段の処理で解凍する
    (PngMetaData metaData, byte[] data) = Decompress(rawData);

    // 展開したピクセルの情報を格納するための構造体の配列を確保
    Pixel32[] pixels = new Pixel32[metaData.width * metaData.height];

    // 1ピクセルのbitのサイズを計算
    byte bitsPerPixel = GetBitsPerPixel(metaData.colorType, metaData.bitDepth);

    // 1行あたりのバイトサイズを計算
    int rowSize = 1 + (bitsPerPixel * metaData.width) / 8;

    // 何バイトずつデータが並んでいるかを計算
    int stride = bitsPerPixel / 8;

    for (int h = 0; h < metaData.height; ++h)
    {
        int idx = rowSize * h;

        // 該当行のフィルタリングタイプを取得
        byte filterType = data[idx];

        int startIndex = idx + 1;

        switch (filterType)
        {
            case 0:
                break;

            case 1:
                UnsafeExpand1(data, startIndex, stride, h, pixels, metaData);
                break;

            case 2:
                UnsafeExpand2(data, startIndex, stride, h, pixels, metaData);
                break;

            case 3:
                UnsafeExpand3(data, startIndex, stride, h, pixels, metaData);
                break;

            case 4:
                UnsafeExpand4(data, startIndex, stride, h, pixels, metaData);
                break;
        }
    }

    Texture2D texture = null;

    // --------------------------------------------
    // ※ テクスチャの生成処理は後述
    // --------------------------------------------

    return texture;
}

PNGデータのフィルタリングの都合上、1行ずつ処理をしていく必要があります。 (※ ほとんどの処理が左隣のピクセルデータに依存しているため)

そのため、まずは行単位で処理を行うようにループで処理をしています。そして各行の最初のバイトに、どのタイプでフィルタリングを施したのかの情報が入っています。それを抜き出しているのが以下の部分です。

byte filterType = data[idx];

データ配列はこのフィルタリングタイプと画像の幅 x 要素数分(RGBAなら4要素=4バイト)を足したサイズを1行分として、それが画像の高さ分並んでいます。なので、フィルタリングタイプに応じて処理を分け、その中で1行分の展開処理を行っていきます。

【フィルタタイプ 0 - None】無加工

フィルタタイプ 0None、つまり無加工です。そのため各ピクセルのデータがそのまま格納されています。

【フィルタタイプ 1 - Sub】左隣からの差分から展開

フィルタリングタイプが 1 の場合は左隣のピクセルからの差分データが並びます。値の算出は以下のようにして左隣のデータを参考にして求めて格納します。

復元する場合はその逆を行えばいので、以下の式で求めることができます。

ピクセルの値 + 左隣のピクセルの値)% 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand1(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    // データサイズを超えていないかチェック
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    // 計算効率化のためポインタを利用して計算
    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        // バイト配列のポインタアドレスをスタート地点まで移動
        byte* p = pin + startIndex;

        // 展開後のデータを格納する構造体配列も同様にポインタ化して位置を移動
        // 注意点として、データが「画像の下から」格納されているため配列の後ろから格納している点に注意。
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        for (int x = 0; x < metaData.width; ++x)
        {
            // ポインタをuint型にキャストして構造体(RGBAの4バイト=uintと同じサイズ)に効率的に値を格納
            *(uint*)&current = *(uint*)p;

            // 左隣から展開する処理を実行
            left = Pixel32.CalculateFloor(current, left);

            // ポインタを要素数分(RGBAの4バイト)進める
            p += stride;

            // 計算結果をポインタ経由で格納し、位置をひとつ分進める
            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculateFloor(Pixel32 left, Pixel32 right)
{
    byte r = (byte)((left.r + right.r) % 256);
    byte g = (byte)((left.g + right.g) % 256);
    byte b = (byte)((left.b + right.b) % 256);
    byte a = (byte)((left.a + right.a) % 256);

    return new Pixel32(r, g, b, a);
}

【フィルタタイプ 2 - Up】上のピクセルの差分から展開

フィルタタイプ 2 は、1 の左からの差分の計算をそのまま上からのピクセルの差分に意味を置き換えたものになります。図にすると以下のようになります。(基本的に左隣のものと対象となるピクセルが違うだけで処理そのものは同じです)

復元する場合は 1 と同様、その逆を行えばいので、以下の式で求めることができます。

ピクセルの値 + 上のピクセルの値)% 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand2(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            up = Pixel32.CalculateFloor(current, up);

            p += stride;

            *pixp = up;
            ++pixp;
        }
    }
}

【フィルタタイプ 3 - Average】左と上のピクセルの平均から展開

フィルタタイプ 3 はある意味、 12 の合せ技のような方法です。左と上のピクセルを求め、その平均を計算したものを結果として採用します。そして最後に、12 同様の計算を行います。言い換えると、1 では左の値を、2 では上の値を、そして 3 では平均の値を元に計算を行う、ということです。

これを復元するには

ピクセルの値 + 左と上のピクセル値の平均) % 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand3(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            left = Pixel32.CalculateAverage(current, left, up);

            p += stride;

            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculateAverage(Pixel32 a, Pixel32 b, Pixel32 c)
{
    int ar = Average(b.r, c.r);
    int ag = Average(b.g, c.g);
    int ab = Average(b.b, c.b);
    int aa = Average(b.a, c.a);

    return CalculateFloor(a, new Pixel32((byte)ar, (byte)ag, (byte)ab, (byte)aa));
}

private static int Average(int left, int up)
{
    return (left + up) / 2;
}

平均計算部分を見てもらうと気づくと思いますが、ただ平均を取るだけではなく、平均を取った値との差分を求めている点に注意が必要です。PNGのデータは常になにかとの差分の結果だ、ということを念頭に入れておくと理解しやすくなると思います。

【フィルタタイプ 4 - Paeth】左・上・左上から推測して展開

参考にさせていただいた記事から引用すると、以下のアルゴリズムで値が決定しているもののようです。

Paethアルゴリズムは、左、上、左上の 3つの隣接するピクセル値から、「この位置に来るであろうピクセル値が、上記 3つのピクセル値のうち、どれと一番近くなりそうか」を予測するために利用される。Alan W. Paethさんが考案した

また計算式も引用させていただくと、以下を満たす位置にあるピクセルの値を採用します。

int PaethPredictor(int a, int b, int c)
{
    // +--------+
    // | c | b |
    // +---+---+
    // | a | ? |
    // +---+---+
    int p = a + b - c;

    // pa = |b - c|   横向きの値の変わり具合
    // pb = |a - c|   縦向きの値の変わり具合
    // pc = |b-c + a-c| ↑ふたつの合計
    int pa = abs(p - a);    
    int pb = abs(p - b);    
    int pc = abs(p - c);    

    // 横向きのほうがなだらかな値の変化 → 左
    if (pa <= pb && pa <= pc)
        return a;

    // 縦向きのほうがなだらかな値の変化 → 上
    if (pb <= pc)
        return b;
        
    // 縦横それぞれ正反対に値が変化するため中間色を選択 → 左上        
    return c;
}

これの復元は以下のように行います。

ピクセルの値 + Paethアルゴリズムによって求まったピクセルの値) % 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand4(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 leftUp = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            if (y == 0 || x == 0)
            {
                leftUp = Pixel32.Zero;
            }
            else
            {
                *(uint*)&leftUp = *(uint*)(pixp + upStride - 1);
            }

            left = Pixel32.CalculatePaeth(left, up, leftUp, current);

            p += stride;

            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculatePaeth(Pixel32 a, Pixel32 b, Pixel32 c, Pixel32 current)
{
    int cr = PaethPredictor(a.r, b.r, c.r);
    int cg = PaethPredictor(a.g, b.g, c.g);
    int cb = PaethPredictor(a.b, b.b, c.b);
    int ca = PaethPredictor(a.a, b.a, c.a);

    return CalculateFloor(current, new Pixel32((byte)cr, (byte)cg, (byte)cb, (byte)ca));
}

private static int PaethPredictor(int a, int b, int c)
{
    int p = a + b - c;
    int pa = Mathf.Abs(p - a);
    int pb = Mathf.Abs(p - b);
    int pc = Mathf.Abs(p - c);

    if (pa <= pb && pa <= pc)
    {
        return a;
    }

    if (pb <= pc)
    {
        return b;
    }

    return c;
}

展開処理の説明は以上です。最後に、この計算で展開されたデータをテクスチャのデータとして渡すことができれば完成です。

構造体の配列を直にバイト配列として読み込む(ポインタからテクスチャを生成)

展開処理の最後はデータを実際のテクスチャデータとして利用することです。ここでは、効率的にテクスチャにデータを渡す方法を見ていきます。

展開処理で見てきたように、計算を簡単にするために Pixel32 という構造体を作りました。計算結果の配列もこの Pixel32 が並んだものになっています。しかし当然、独自で作成した構造体なのでこれをそのままテクスチャのデータとして読み込ませることはできません。

しかし、Texture2D.LoadRawTextureDataポインタを受け取ることができ、メソッド名からも分かる通りテクスチャのピクセルを表すバイト配列を期待しています。なので、Pixel32 の配列をバイト配列として認識させれば引数に渡すことができます。

構造体はシンプルに、フィールドを順番に並べたものになっています。つまり RGBA の4バイトが順番に並び、それが配列になっているので実質Raw dataと見なすことができるわけです。

前置きが長くなりましたがやることはシンプルです。Pixel32 の配列をポインタに変換して、それを引数に渡すことで簡単に実現することができます。百聞は一見にしかずということでコードを見てみましょう。

Texture2D texture = null;
unityContext.Post(s =>
{
    texture = new Texture2D(metaData.width, metaData.height, TextureFormat.RGBA32, false);

    // GCの対象にならないようにハンドルを取得
    GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);

    try
    {
        IntPtr pointer = handle.AddrOfPinnedObject();
        texture.LoadRawTextureData(pointer, metaData.width * metaData.height * 4);
    }
    finally
    {
        if (handle.IsAllocated)
        {
            // GCされるように解放
            handle.Free();
        }
    }

    texture.Apply();
}, null);

while (texture == null)
{
    Thread.Sleep(16);
}

return texture;

pixelsPixel32 構造体の配列です。これの GCHandle を取得し、 AddrOfPinnedObject() メソッドから配列の先頭アドレスを得ます。戻り値は IntPtr なのでこれをそのまま LoadRawTextureData() メソッドに渡すことで読み込むことができます。第2引数はデータのサイズです。

最後に

今回は学習目的での実装だったのでそこまで最適化をしていません。そのため Texture2D.LoadImage で読み込む処理に比べるとだいぶ遅いです。(大体10倍くらい遅い)

ただUnity APIを利用していないのでスレッドで実行できますし、前回の記事のようにテキストチャンクにデータを仕込んでそれを取り出す、ということもできます。ちょっとした付与データ込みの画像データを保存する、とかであれば現状でも用途があるのかなと思っています。

最初に実装したのは配列をそのまま利用していたためさらに処理が重かったのですが、ポインタ経由にすることで3倍くらいは速くなりました。ポインタを利用しての最適化は他の場所でも使えるので覚えておいて損はないかなと思います。

いちおう、特定用途ではありますが実用に耐えうるものとしてより最適化をしていこうと思っています。(できたらBurst対応とかもしたい)

もし最適化が出来て実用に耐えうるものになったらそれも記事に書こうと思います。