e.blog

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

ECSを使ってTextMesh Proの文字を大量に描画する

前回の記事では、ECS自体の使いどころやそもそもなぜ高速化するのかという点について書きました。

edom18.hateblo.jp

記事の中で紹介した動画はTextMesh Proの文字を利用して大量に文字を空間に表示するというものでした。

こんなやつ↓

概要

今回はこのTextMesh Proの文字をECSで大量に描画する方法について書いていこうと思います。

今取り組んでいるプロジェクトで文字を大量に表示する必要があるため、ECSが利用できそうだったので実装してみました。

ちなみに先に注意点を書いておくと実はTextMesh Proそのものを描画しているわけではありません。実際にはQuadなMeshに対してTextMesh Proの文字のテクスチャアトラスを利用して描画しています。

具体的に言うと、TextMesh Proの持っているグリフというフォントに関する情報を利用してUVを算出して描画するということをしています。

なので今回はTextMesh Proの文字をどうやって表示したか、どう動かしているかについて書いていこうと思います。

今回の記事で解説している内容はGitHubにもアップしてあるので、実際に動作するものを見たい方はリポジトリをクローンして見てください。

github.com



TextMesh ProのテクスチャアトラスのUVを算出する

冒頭でも書いたように既存のTextMesh Proの文字をそのままECSで動かすことはできません。今回の実装はTextMesh Proの Glyph を用いてテクスチャアトラスのUV位置を算出し、それをECS上で描画できるようにしたものです。

そのためTextMesh Proの Glyph データからUVなどを算出する方法について解説します。

Glyphについて知る

TextMesh Proでは Glyph (グリフ)の情報を用いてテキストの描画を行っています。まずはこのグリフについて理解します。

グリフとは

モリサワのサイトから引用すると以下のように説明されています。

字体とほぼ同義語ですが、記述記号やスペースなども含めたものを指します。

慣用的にはデータとしての字体を指す場合に使われることもあります。これらの文字と記号類を集めたものがグリフセットと呼ばれるもので、これは文字セットや文字コレクションとほぼ同義と考えてよいでしょう。

つまり、文字をレンダリングする際に必要となるデータ、という感じですね。

TextMesh Proのグリフを使ったメッシュ作成のコード断片を見るとなんとなくイメージがわくと思います。

// TMP_Character型のデータからGlyphを取得する
Glyph glyph = tmpCharacter.glyph;

// 中略

float x0 = -glyphWidth * 0.5f;
float x1 = glyphWidth * 0.5f;
float y0 = -glyphHeight * 0.5f;
float y1 = glyphHeight * 0.5f;

Vector3[] vertices = new[]
{
    new Vector3(x0, y0, 0),
    new Vector3(x0, y1, 0),
    new Vector3(x1, y1, 0),
    new Vector3(x1, y0, 0),
};

こんな感じで Glyph 情報から文字の幅や高さ、またそれ以外でもカーニングや表示位置など様々な情報を得ることができます。つまり、文字を記述するための情報が得られる、というわけです。

※ 上記のコード例はUVではなく、文字のサイズなどに合わせたメッシュを生成するコードの一部です。今回の描画には直接的には関係ありません。

Glyphから情報を抜き出す

グリフ情報で様々な情報を得ることができることが分かりました。これらの情報を利用して、テクスチャアトラスのUV位置を算出します。

UVを算出する

さっそく、グリフ情報からUVの値を算出しましょう。

ただ、この算出に当たって注意点があります。通常、UVは各頂点ごとに設定されます。Quadのような形状であれば都合4つのUVの値が必要となります。しかし今回はメッシュを生成せず、デフォルトのQuadメッシュを利用するため頂点ごとにUVの値を設定することができません。そのため、少しだけ工夫が必要になります。

まず情報を整理すると、Quadのメッシュは左下が 0, 0、右上が 1, 1 となるUV値を持っています。図示すると以下のような値を持っています。

この値を加工してテクスチャアトラスのUVに合うようにできれば達成できそうです。

イメージとしては正方形を、望みの長方形に変形(縮小)した上で、テクスチャアトラスの該当位置まで移動させれば達成できそうですね。

次の画像がオフセットとスケールを調整するイメージです。ここでは「悟」という字に対して計算を行おうとしています。 ここでのゴールは、この「悟」という部分の赤い矩形の位置・サイズにぴったり重なるようにUVを加工することです。順に手順を見ていきましょう。

左下からX Offsetだけ右に移動し、Y Offsetだけ上に移動すると、「悟」の字の位置に原点( 0, 0 )が移動しますね。

文字サイズはそのままグリフ情報の幅と高さが使えます。そしてこれがそのままスケール値となります。

例えばスケールの値が横 0.05、縦 0.08 とした場合、Quadの4つのUVの値すべてに掛けて上げるとそれぞれ以下のようになります。(疑似コードで示します。左下から時計回りに値を設定していると仮定します)

float2 uv0 = new float2(0.0f * 0.05f, 0.0f * 0.08f);
float2 uv1 = new float2(0.0f * 0.05f, 1.0f * 0.08f);
float2 uv2 = new float2(1.0f * 0.05f, 1.0f * 0.08f);
float2 uv3 = new float2(1.0f * 0.05f, 1.0f * 0.08f);

// それぞれの値は以下になる。
// uv0 = ( 0.0,  0.0)
// uv1 = ( 0.0, 0.08)
// uv2 = (0.05,  0.0)
// uv3 = (0.05, 0.08)

ちゃんと望みの値が得られていることが分かりますね。あとはこれに、前述のオフセットを足してやれば無事、テクスチャアトラスの文字をサンプリングするUVが得られる、というわけです。

この前提を元にUV値を算出している計算が以下です。

// グリフ情報を取得
Glyph glyph = tmpCharacter.glyph;

// グリフのUVを計算
float rectWidth = glyph.glyphRect.width;
float rectHeight = glyph.glyphRect.height;
float atlasWidth = fontAsset.atlasWidth;
float atlasHeight = fontAsset.atlasHeight;
float rx = glyph.glyphRect.x;
float ry = glyph.glyphRect.y;

float offsetX = rx / atlasWidth;
float offsetY = ry / atlasHeight;
float uvScaleX = ((rx + rectWidth) / atlasWidth) - offsetX;
float uvScaleY = ((ry + rectHeight) / atlasHeight) - offsetY;

float4 uv = new float4(offsetX, offsetY, uvScaleX, uvScaleY);

ポイントはそれぞれの値の算出に、テクスチャアトラスのサイズを利用して正規化している点です。これによって適切にオフセットとスケールが求まります。

求めたこの値を使って、QuadのUVを加工することでテキストをECSでレンダリングすることができるようになります。

以下は、その計算を行っているShader Graphの様子です。

算出したUVのスケールの値を、Quadのスケールに乗算したあと、最後にオフセットを加算したものを最終のUVとしている様子です。

カスタムのUVをマテリアルに反映させる

UVの値を求め、それを利用するシェーダを準備することはできましたが、各文字のQuadごとに異なるUVを設定しなければなりません。通常の、MonoBehaviour なオブジェクトであれば個別にマテリアルに値を設定したり、あるいは MaterialPropertyBlock を使って設定することができます。しかし、ECSではそうした方法が使えません。

ではどうするのかというと、ECS側でオブジェクトごとに値を設定する方法が用意されているのでそれを利用します。

以下がそのドキュメントです。

シェーダ側の準備

ドキュメントに沿って方法を解説していきます。まずはシェーダ側の準備です。Shader Graphのプロパティの設定に Override Property Declaration という項目があります。これをまずオンにします。すると Shader Declaration という項目が設定できるようになるので、これを Hybrid Per Instance に変更します。

今回はカスタムのUVの値をオーバーライドしたいので CustomUv の設定でこれを行っています。これを設定しているのが以下の図です。

シェーダの設定は以上です。

カスタムUV用のコンポーネントを用意する

次に準備するのはコンポーネントです。ECSではコンポーネント、つまりデータが中心に存在するため、こうしたデータ周りはコンポーネントが担います。そしてECSでは、前述のマテリアルのオーバーライドを実現する方法を用意してくれているので、それに従ってコンポーネントを定義します。

大事な点は2点で、その他のコンポーネントと同様に IComponentData インターフェースを実装しつつ、さらに MaterialProperty 属性を付与する点です。属性の引数にはオーバーライドしたいプロパティ名を指定します。

具体的には以下のようになります。(抜粋ではなく、これはコード全文です)

using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;

[MaterialProperty("_CustomUv")]
public struct CustomUvData : IComponentData
{
    public float4 Value;
}

値は、Shader Graphで定義したものと同じ型を指定します。(ここでは float4

そして MaterialProperty 属性の引数には _CustomUv を指定しています。あれ、 CustomUv じゃないの? と思われた方もいるかもしれませんが、設定の画像を見てもらうと Reference という項目の設定が _CustomUv になっているのが分かると思います。これは実際にシェーダで利用される変数名、ということなわけですね。なのでこれを指定します。

コンポーネントをEntityに追加する

最後に、定義したコンポーネントをEntityに登録します。登録はその他のコンポーネントとまったく同じです。

CustomUvData uvData = GetCustomUvData(index);
entityManager.AddComponentData(entity, uvData);

CustomUvData の生成処理は前述のUV算出のところで説明したものです。あとはそれをEntityManagerを通して登録してやればOKです。これをマテリアルに適用する処理はECS側のシステムが自動で行ってくれるため、特に開発者側でなにかをする必要はありません。

文字サイズを設定する

最後に計算するのは文字サイズを設定することです。今回利用しているのはデフォルトのQuadメッシュです。これは1m x 1mのサイズの面になるのでそのままだとかなり大きいポリゴンになってしまいます。

またそれ以外にも、本来は文字ごとにメッシュのサイズが異なります。例えば文字の Ax! ではメッシュサイズが異なります。

ポリゴン形状を可視化するとこんな感じです。

この違いを各Quadに適用するのがここで解説する内容です。

グリフからメッシュのスケールを計算する

UVの計算で行ったのと似たようなことをします。UVの場合はテクスチャの位置とスケールを求めていました。今回はグリフからメッシュのサイズ、つまりQuadのスケールを計算します。

// フォントサイズ
private float FontSizeToUnit => _fontSizeInCm * 0.01f;

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

// グリフ情報を取得
Glyph glyph = tmpCharacter.glyph;

// グリフの幅と高さを計算
float toUnit = FontSizeToUnit * (1f / fontAsset.faceInfo.pointSize);

float glyphWidth = glyph.metrics.width * toUnit;
float glyphHeight = glyph.metrics.height * toUnit;

return new float3(glyphWidth, glyphHeight, 1f);

冒頭のフォントサイズは、デフォルトだと1m x 1mと巨大なので、それを補正( * 0.01f )しつつ、SerializeField で指定されたフォントサイズの大きさに調整するものです。例えばフォントサイズを 24 とした場合は実際には24cmの大きさになる、という具合です。

幅と高さの計算では、フォントサイズに対してフォントフェイスの持っているポイントサイズの逆数を掛けることで、続く幅などの値を正規化しています。( glyph.metrics.width などはフォントフェイスサイズになっているため)

そして最終的に幅と高さに対して前述の toUnit を掛けることで想定したサイズが求まります。

ここで求めた値はメッシュのサイズですが、適用するQuadは 1 x 1 のサイズなので、結果的にそのままこのサイズがスケールの値となるわけです。

以上で文字周りの生成、計算が終わりました。

次に、これらのメッシュを描画する手順について見ていきましょう。

Entityの描画はRenderMeshArrayを使う

まずはEntityを作成します。

World world = World.DefaultGameObjectInjectionWorld;
EntityManager entityManager = world.EntityManager;

Entity entity = entityManager.CreateEntity();
entityManager.SetName(entity, $"TextMeshEntity {index.ToString()}");

ECSのシステムそのものの解説はここでは割愛しますが、基本的な生成フローです。ECSのワールドからEntityManagerを取得し、それを利用してEntityを作成しています。

次に、生成したEntityに、描画するための設定を行っていきます。

※ そもそもECSは計算効率を最大化する目的なので必ずしもすべてのECSが描画されるとは限りません。そのため、描画したい場合は専用のコンポーネントなどを適切に設定する必要があるわけです。

RenderFilterSettings filterSettings = RenderFilterSettings.Default;
filterSettings.ShadowCastingMode = ShadowCastingMode.Off;
filterSettings.ReceiveShadows = false;

RenderMeshDescription renderMeshDescription = new RenderMeshDescription
{
    FilterSettings = filterSettings,
    LightProbeUsage = LightProbeUsage.Off,
};

RenderMeshArray renderMeshArray = new RenderMeshArray(new[] { _material }, new[] { _mesh });

まずはコンポーネントの設定に必要なデータの定義から。

冒頭の RenderFilterSettingsRenderMeshDescription は描画に関する設定項目です。影を落とすか、Light Probeの影響は、などを設定しています。

その次にある RenderMeshArray がオブジェクト自体の設定になります。今回は MeshMaterial もひとつだけ設定していますが、配列で指定することで複数のメッシュとマテリアルをひとつにまとめて設定することができます。

最後は描画するEntityの設定において重要な RenderMeshUtility.AddComponents メソッドです。これは、描画に必要なコンポーネントを適切に設定してくれるヘルパーメソッドです。

RenderMeshUtility.AddComponents(
    entity,
    entityManager,
    renderMeshDescription,
    renderMeshArray,
    MaterialMeshInfo.FromRenderMeshArrayIndices(0, 0));

RenderMeshUtility のクラスコメントを見ると以下のように書かれています。

/// Helper class that contains static methods for populating entities
/// so that they are compatible with the Entities Graphics package.

/// エンティティを実装するための静的メソッド含むヘルパークラス。
/// これによりEntities Graphicsパッケージに適合させることができる。

このユーティリティの AddComponents メソッドを通して描画に必要なコンポーネントが設定されます。適切にコンポーネントが設定されたEntityがある場合、ECSのシステム側で自動的に描画まで行ってくれます。これで文字が画面に表示されるようになりました。


RenderMesh と RenderMeshArray

ECSの描画について調べていく際、 RenderMesh を利用するということが書かれている記事もあり混乱しました。特に、見出しにある RenderMeshArray は配列を示唆することから、ひとつのEntityを描画するのに冗長なのでは、と思ってしまったのが混乱の元でした。

結論から言うと RenderMesh は現状ではどうやら非推奨となっており、 RenderMeshArray を利用するのが正規の方法のようです。なぜ配列を利用するのかは、そもそもECSを利用するモチベーションである「大量にオブジェクトを処理する」という観点から考えると自明です。

つまり、描画に関するコストを最小限にしたいという思想があり、そのためにメッシュを配列で持ち、それを切り替えることでGPUのステートの変更を最小限にする、ということを実現するためだと思われます。

ちなみに参考にした記事から引用させてもらうと以下のように書かれていました。

私が機能を見落とした可能性は否定できませんが、 以前まで使用されていたInstancing無しの描画クラスであるRenderMeshについて以下のような記述があり、サポートがされていないようでした。

// RenderMesh is no longer used at runtime, it is only used during conversion.
// At runtime all entities use RenderMeshArray.

そのため、オブジェクトがEntity化されると自動的にプログラム側はDOTS Instancingの適用条件を満たすということになります。 Shader側は別途条件を満たすために処理を追加する必要があります。

とのことなので、基本的に RenderMeshArray を使っておけば問題ないでしょう。


文字を動かすシステムを作成する

最後は文字を動かすシステムについて見ていきます。

システムの詳細についてはここでは割愛します。システムの実装方法やデータの取り回しについては前回の記事を参照してください。ここでは、今回作成したシステムそのものについてだけ解説します。

文字の動きを制御するジョブ

まず最初に見るのは、今回の文字を動かしている要でもあるジョブについてです。ECSでもC# Job Systemを使ってワーカースレッドで処理することができます。基本的に ISystem を実装したシステムであればジョブシステム化することも容易でしょう。

ECS用のジョブとして IJobEntity というインターフェースが用意されています。今回はこれを実装します。

今回の文字の動きを制御しているジョブの実装は以下のようになっています。

[BurstCompile]
partial struct TmpUpdateJob : IJobEntity
{
    public double Time;

    private void Execute([EntityIndexInQuery] int index, ref MeshInstanceData meshData, ref LocalToWorld localTransform)
    {
        double move = math.sin((Time * meshData.TimeSpeed + index) * math.PI) * meshData.MoveSpan; // index is just offset for the time.
        float3 position = meshData.Position + new float3(move);

        float angleSpeed = 0.005f;
        float angle = (float)math.sin(Time * meshData.TimeSpeed * angleSpeed * math.PI) * 360f;
        quaternion rotation = math.mul(meshData.Rotation, quaternion.RotateY(angle));
        
        localTransform.Value = float4x4.TRS(position, rotation, meshData.Scale);
    }
}

冒頭の [BurstCompile] 属性によってバーストコンパイラによるコンパイルを指示しています。バーストコンパイラコンパイルすることができれば相当な高速化が見込めます。積極的に使っていきましょう。

実装自体は Execute メソッドを実装するだけです。しかし実は IJobEntity インターフェース自体はなにも宣言していない、ある意味マーカーのようなインターフェースとなっています。おそらくですが、ECSの大半がソースジェネレータによってコードが自動生成されるため、インターフェース周りも同じような制御になっているのでしょう。

そのため、 Execute メソッドの定義は必須となっていますが、引数に指定するものは柔軟に指定することができます。特に、そのジョブで利用するデータ型を指定することで、実行時に適切に対象データをシステムが提供してくれるようになります。

今回の例で言えば以下の部分ですね。

void Execute([EntityIndexInQuery] int index, ref MeshInstanceData meshData, ref LocalToWorld localTransform);

第一引数の [EntityIndexInQuery] 属性は、クエリの中でのEntityの位置を示しています。これを利用すると、コンピュートシェーダのスレッドIDのような使い方ができます。

そして続く第二、第三引数には実際に利用するコンポーネントの型を指定しています。今回はこのふたつだけのコンポーネントが必要でしたが、もしこれ以外にも必要な場合は引数として定義してやると、ECSのシステムが引数に対象コンポーネントを渡してくれるようになります。

値を更新するデータの場合は ref を、参照だけ(つまり読み取りだけ)する場合は in を指定します。あとはメソッドの実装部分で該当データを使って処理を行うだけです。今回は MeshInstanceData に初期値が入っているので、適当にSin関数などで回転や移動をしているだけです。

実際のプロジェクトではもっと意味のある処理をする必要がありますが、どうやって実装していくかがなんとなくイメージできるかと思います。

システムの全体を実装する

ジョブの実装が終わったので、あとはこのジョブを利用するシステムを実装します。今回のシステムではトランスフォーム、つまりメッシュの姿勢を制御する前に値を更新したいため、 [UpdateBefore()] 属性を指定して、トランスフォームの更新システムの前に処理されるように指示しています。

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[UpdateBefore(typeof(TransformSystemGroup))]
public partial struct TmpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MeshInstanceData>();
        state.RequireForUpdate<LocalToWorld>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var job = new TmpUpdateJob()
        {
            Time = SystemAPI.Time.ElapsedTime,
        };
        job.ScheduleParallel();
    }
}

OnCreate メソッドでは、必要とするコンポーネントの種類を指定することができるため、今回は MeshInstanceDataLocalToWorld コンポーネントを持つEntityを対象とすることをシステムに伝えています。

そして OnUpdate で実際にジョブを生成し、スケジュールします。

システムの全体像は以上です。描画に関してはECSの RenderMeshSystem が自動で行ってくれるため、これ以上の実装は必要ありません。

まとめ

最終的に、テキストの色指定なども加えて以下のような見た目になりました。(iPhone 15 Proで動かしても60FPSを達成できています)

ECSを利用すると、メモリアクセスの効率、ひいてはデータ転送の効率がいかに遅いかに気付かされます。処理負荷というとつい、計算のアルゴリズムや大量のオブジェクトの問題に目が行きがちですが、一番のボトルネックは「データ転送速度」というわけなのですね。

まったくの余談ですが、PolySpatialを用いたApple Vision Pro向けアプリ開発ではカスタムシェーダがほぼ使えず、Compute Shaderを用いたパーティクルなどの描画が行えません。現状ではECSもレンダリングできないのですが、シェーダのカスタムよりは実現可能性が高いのかな、と思っているので密かにECSに期待しています。

ぜひみなさんもECSで大量のオブジェクトをコントロールする楽しさに触れてみてください。

ECSの仕組みを理解し、使いどころを把握する

もともとECS/DOTSには興味があって知りたいと思っていたのですが、なかなか実プロジェクトで使うタイミングがなく放置してしまっていたのですが、今開発している中で利用できそうな箇所があったので、改めて入門して、備忘録的に学んだことをまとめていきたいと思います。

unity.com

ちなみに、ECSを使ってTextMesh Proの文字を大量に(80,000文字)出してみたら、余裕で60FPS出る状態でした。(PCではありますが、プロファイラで見ると4msくらいしかかかってなかったので全然まだ余裕があった)



概要

本記事ではECSの内容を概観し、全体像を把握して使い所を把握することを目的に書いています。ECSの実装方法については簡易的なサンプルを示しつつ、ECSのコアであるデータ指向の部分について、なぜ高速化するのか、どういうところで真価を発揮するのかについて書いていきたいと思います。

ECSとは

ECSは「Entity / Component / System」の頭文字を取ったものです。これは、EntityとComponentでオブジェクトとデータを定義し、Systemによって振る舞いが処理されることからついている名前のソフトウェアアーキテクチャです。ECS自体の概念はUnity特有のものではなく、効率的な処理を目的としたソフトウェア開発で採用されているアーキテクチャです。

なぜECSアーキテクチャだと高速化するのか

ではなぜ、ECSアーキテクチャだと処理が高速化するのでしょうか。その謎を紐解くには、現代のコンピュータの状況を考える必要があります。2024年時点でのCPUは大体1GHz~5GHzのクロック周波数で動きます。ざっくり計算で、1GHzだとすると1秒間に10億回の演算ができることになるので、1クロックあたりの時間は 1 / 1,000,000,000 = 0.000000001秒 となり、1ナノ秒となります。

かなり高速に演算することができることが分かりますね。では次に、メモリからデータを取得するための時間はどれくらいかかるか見てみましょう。以下の記事から引用すると、

各種メモリ/ストレージのアクセス時間,所要クロックサイクル,転送速度,容量の目安 #コンピュータアーキテクチャ - Qiita

下記記事によると,前述のInetl Core i9-13900K からアクセスした 実測値だと86.8ns でした.
【Hothotレビュー】6GHzのマイルストーンに達したCore i9-13900KSの性能をチェック - PC Watch

と書かれていて、メインメモリからの転送時間は実に 86.8ns もかかっています。もちろん状況によって変化することはありますが平均的には60~80nsくらいでしょう。とすると、1GHz程度のCPUから見ても実に60~80倍以上遅い結果となっています。

このことから、コンピュータによる計算における最大のボトルネックはCPUとメモリ間のデータ転送と見ることができます。

ECSはCPUのキャッシュを最大活用する

前述のように、CPUとメモリ間でのデータのやり取りが大きなボトルネックになることが分かりました。しかし一般的に、CPUにはメインメモリ以外にもL1, L2, L3というキャッシュ機構が用意されています。L1から順にCPUに近いキャッシュメモリとなります。前述の記事からさらに引用させてもらうと各キャッシュのアクセスに必要なクロックは以下のように記載されています。

1次キャッシュメモリ(level 1 (L1) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,4クロックサイクル でアクセスできます.


2次キャッシュメモリ(level 2 (L2) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,10クロックサイクル でアクセスできます.


3次キャッシュメモリ(level 3 (L3) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,34クロックサイクル以内,10.27ナノ秒以内 でアクセスできます.

一番近いL1キャッシュでは実に4サイクル(CPUが4回演算する時間)でデータを取り出すことができます。1GHzのCPUであれば 4ns ですね。メインメモリと比較して20倍以上高速です。しかし、CPUに近いキャッシュメモリほど容量が小さく、一度に保存できるデータ量が制限されてしまいます。記事によると2022年時点でもL1キャッシュは64KB程度しかありません。メインメモリが最近ではGB単位あることを考えると相当に小さいことが分かります。

メインメモリを本棚、キャッシュを机として考えてみる

キャッシュを利用するイメージを例え話で考えてみましょう。

L1キャッシュは自身の机で、メインメモリは本棚だと考えてみます。こう考えると、メインメモリにアクセスするのは本棚に本(資料)を取りに行くこと、L1キャッシュに保存するのはそれを机に置くこと、と考えることができます。

こう考えると、本棚から持ってくる本がデータで、机に置いておく本がキャッシュされたデータ、ということになりますね。

さてでは、どうやったら効率的に本を利用できるでしょうか。言い換えると、どうやったら本棚との往復を最小限にできるでしょうか。まず思いつくのは一度の往復で本をたくさん持ってくることです。しかしそれだけだと、使わない本ばかりを持ってきてしまっても意味がありません。なので、できるだけたくさんの使える本を持ってきて机に置いておくことが重要ですね。

CPUとメインメモリ・キャッシュ間の関係もまったく同じなので、できるだけ、メインメモリから取得したデータを効率よく扱えるようにしたい、と思うのは自然な発想でしょう。

そしてまさにこの「データのキャッシュ性を高める」ことを実現しているのがECSというアーキテクチャなわけです。


ECS向けのデータ構造

なぜECSが高速に動くのか、その理由がメモリのキャッシュ効率を最大化することだ、というのは前述した通りです。ではECSではどのようにこれを実現しているのでしょうか。

データ構造を定義するArchetype

ECSではデータはC、つまりComponentが担います。UnityのECSではEntityの持つComponent群を Archetype というタイプごとに管理をするようになっています。アーキタイプは構造のタイプというですね。ここで言う構造は言い換えると「どんなコンポーネントを持っているエンティティか」となります。

例えばコンポーネントの種類が A B C と3種類あるとしましょう。そしてEntityは任意のコンポーネントを持つことができます。例えば、Entity1はコンポーネントA, B、Entity2はコンポーネントA, B, Cを持つ、という具合です。

そしてこの「コンポーネント A, B, Cを持つ」という事実を Archetype として定義することで、あとからデータを取得しやすくしているわけです。

公式の動画で分かりやすい図が紹介されていたので引用させていただきます。

データを実際に配置するChunk

前述の Archetype はいわば概念です。「こういうコンポーネント郡を持っているエンティティにラベルを貼る」という感じですね。しかし概念だけではコンピュータは動きません。特に、メモリのキャッシュ効率を最大化することが目的なので、メモリレイアウトにはかなり気を使う必要があります。そしてこの「どういうふうにデータをメモリ上に配置するか」という実装に関するものが Chunk となります。

このチャンクの仕組みを視覚的に説明してくれている動画がUnityの公式にあります。

www.youtube.com

上の動画から、該当部分のアニメーションを抜き出すと以下のように説明されています。(動画ではKeijiroさんが詳しく解説してくれているので、興味がある方はぜひ観てみてください)

まずは、一般的なオブジェクト指向な場合のメモリレイアウト、メモリアクセスの様子です。

次に、ECSによるデータ指向なメモリレイアウト、メモリアクセスの様子です。

前者はメモリ上にデータがバラバラに点在しているためアクセスがあちこちに飛んでいるのが分かります。一方後者はデータがメモリ上に連続的に並んでおり、効率よくアクセスできていることが見て取れます。

CPUのアーキテクチャは通常、メインメモリアクセスが発生した場合、キャッシュラインという単位でまとめてデータを取得し、それをキャッシュに載せます。CPUのアーキテクチャでは「空間的局所性」と「時間的局所性」に基づいてこうしたキャッシュを利用しようとします。

ここで言う局所性とは、演算対象のデータの近く(空間)のものはすぐに使われる、一度アクセスしたデータは近く(時間)アクセスされる、ということをベースとして考えられています。つまり、ECSのデータ指向なメモリレイアウトはこの考え方に非常にマッチしている、というわけですね。メモリ上に連続してデータが並んでおり、かつ処理もそのデータ単位でまとめて行ってしまおう、というのがコンセプトなのですから。

前述のように、メモリアクセスは演算からすると数十倍もの開きがあります。これを最適化できれば、単純計算で処理が数十倍になる、というわけですね。(とはいえ、そこまで単純な話ではありませんが)

メモリレイアウトを深堀りする

さて、局所性に基づいてメモリレイアウトが決まるということを話しました。では実際、 Chunk はどういう形でデータを保持しているのでしょうか。

Unity公式動画の図を再掲します。

以下が公式の動画です。興味がある方はこちらも観てみてください。

www.youtube.com

上記の図をイメージしつつ、中身を見ていきましょう。

ChunkはArchetypeごとのデータとEntityの配列を持つ

前述したように、ECSでは Archetype と呼ばれる型によってチャンクが決まり、そのチャンクの中にコンポーネントが配列として保持されるということでした。

そしてそのChunk内にはコンポーネントだけではなく、Entityのリストも保持されるようです。以下のフォーラムでのやり取りから抜粋します。

forum.unity.com

Entities
All entities are stored in a single EntityData struct array. Entity.index is the index into this array and EntityData provides a direct address to its Components. Is an Entity struct also stored in the chunk so it can refer back to the entities array? This is what EntityArray is generated from?
As a user can store Entity, am I right in assuming that the items in the entities array never change position? If you add 1000 entities and remove the first 999, that last entity is still going to be at the 1000th index?

特に太字の部分ですね。これは、エンティティ構造体がChunk内に含まれるか、という問いです。そしてそのEntity構造体から、 EntityArray にアクセスできるか、という質問です。

この質問に対するUnityの中の人の回答は以下でした。

Yes. In fact we have essentially an Entity as the 0 component. This is what EntityArray is using internally.

Entity 自体は実際に 0 コンポーネントとして保持しているとのこと。よく考えてみればある意味で自明ですね。チャンク内にコンポーネントデータの配列があったとしても、そのコンポーネントがどのエンティティのものなのか分からなければ使いようがないですからね。

実装方法

以上で概念的なところは終了です。以下からは実際にどうやって実装していくかについて見ていきます。ただ、ここでは簡単に状況を整理するだけにとどめます。細かな実装方法や実際に活用する方法については別の記事にゆずります。あくまで概観することを目的にしています。

データを定義する

まず最初に説明するのはデータについてです。データ指向とも呼ばれているのでデータが中心に存在します。UnityのECSではデータは IComponentData インターフェースを実装することになっています。


ちなみにコンポーネントのコンセプトとして、ドキュメントにはこう記載されています。

Use the IComponentData interface, which has no methods, to mark a struct as a component type. This component type can only contain unmanaged data, and they can contain methods, but it's best practice for them to just be pure data. If you want to create a managed component, you define this as a class. For more information, refer to Managed components.

太字の部分を見てみると、インターフェースを利用する意味はただのマーカーとしてのようですね。


具体的には以下のように定義します。

public struct SampleTransformData : IComponentData
{
    public float3 Position;
    public quaternion Rotation;
    public float3 Scale;
}

IComponentData インターフェースを実装し、姿勢制御に必要な3つの要素を持つデータを定義している様子です。注目してもらいたいのは struct で定義している点です。基本的にECSでは struct、つまり構造体を使って構築していくのがベストプラクティスです。

理由は前述したメモリレイアウトの問題にあります。クラスを利用してしまうと、これはマネージドな領域にデータが確保されてしまうため、結果的にECSの最大のメリットであるメモリ効率性に影響が出てしまいます。

しかし構造体で定義した場合、その配列はメモリ上に連続的に配置され、ECSのコンセプトであるメモリアクセスの効率化につながるというわけです。

データはstruct / class両方で定義できる

ただ、現実問題として必ずすべてを構造体で、というのはむずかしい局面もあるでしょう。その場合にはクラスで定義することが可能となっています。これをマネージドコンポーネントと呼び、このコンポーネントではマネージドなデータ、つまりクラスの参照を持つことができます。

ドキュメントから引用すると以下のようになります。

public class ExampleManagedComponent : IComponentData
{
    public int Value;
}

ただし当然デメリットもあり、メモリレイアウトの問題もそうですが、こうしたデータは BurstCompilerコンパイルすることができなかったりするので使用は一部にとどめておくのがいいと思います。

システムを構築する

次に解説するのはECSの「S」、Systemについてです。(ECSの最初のEntityが最後なんかい、と思うかもしれませんが、後述しますがEntityはあまり書くことがないのでこの順番にしています)

ECSにおけるシステムは、一言で言い表すならば「データを処理する担当者」です。ECSの世界では(実際、 World というクラスが存在する)様々なシステムが多数存在し、それぞれが自身の役割をまっとうしていきます。

データは基本的に独立しており、それぞれが独自に処理されても問題ない、言い換えると並列化可能なものが多くあるということです。

もちろん、レンダリングに必要なデータの更新などは先にしてから描画を行うなどの「順番」はあります。しかし、とあるシステムの処理に介入してなにかをする、ということはありません。そのため各システムを独立して実装し、結果として整合性が取れていればいいということになります。

以下のキャプチャはECSで実行されているシステムのリストです。Unity から始まるのはUnityが用意しているシステムです。中央やや下あたりに赤線を引いた場所がありますが、これは自分で作成したシステムです。

このように、様々なシステムが駆動して処理を行っているのが分かるかと思います。

システムはクエリを利用してデータを取得して処理する

大まかにシステムの処理がどういうフローになるかを概観しておきます。

システムは毎フレーム実行されます。MonoBehavior のように毎フレーム OnUpdate が呼び出されます。ここに処理を書いていくことになります。

そしてこの処理の中で、「自分が必要とするデータをクエリして取得」し「取得したデータを加工する」というのが大まかな流れになります。

ざっくりしたコード例は以下です。

public void OnUpdate(ref SystemState state)
{
    float deltaTime = SystemAPI.Time.DeltaTime;

    foreach (var localTransform  in SystemAPI.Query<RefRW<LocalToWorld>>())
    {
        // do something
    }
}

システムの実装方法は2種類

コンポーネントのところでも書きましたが、マネージド・アンマネージドの2種類に応じて実装方法が異なります。

アンマネージドなシステム

アンマネージドなシステムの場合は、コンポーネントと同様に struct で定義します。簡単な例を示すと以下のような形です。

public partial struct TmpSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;

        foreach (var localTransform  in SystemAPI.Query<RefRW<LocalToWorld>>())
        {
            // do something
        }
    }
}

注目ポイントとして partial struct で定義をしているところです。これは、Unityエンジン側がソースジェネレータ機能を用いて他の必要な部分を自動生成するために partial が必要になっています。そして ISystem を実装することで自動的にシステムとして認識され、起動されるようになります。

マネージドなシステム

続いてマネージドなシステムです。マネージドシステムは SystemBase を継承することで実現します。

ドキュメントから一部引用すると以下のような形になります。

public partial class ECSSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // do something.
    }
}

マネージドなシステムも同様に partial として定義する必要があります。それ以外は基本的に ISystem のものと同じです。大きな違いとしてはマネージドなオブジェクトを持てること、そしてバーストコンパイラを利用できないことが挙げられます。

しかしながら、ECSを利用するモチベーションはそのパフォーマンスの高さにあるため、できるだけ ISystem で実装するのがいいでしょう。

ドキュメントにも以下のように記載されています。

In general, you should use ISystem over SystemBase to get better performance benefits.

一般に、高いパフォーマンスを得るためには SystemBase ではなく ISystem を利用すべきです。

実際の実装例

次回の記事で書く予定の内容から、実際に実装したシステムのコードを例として示します。以下は、TextMesh Proの文字をメッシュ化してたくさん表示する、というのをECSで実現した際のコードです。なんとなく、どういう流れで処理をするのか分かると思います。

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public partial struct TmpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MeshInstanceData>();
        state.RequireForUpdate<LocalToWorld>();
    }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;
        double time = SystemAPI.Time.ElapsedTime;

        foreach (var (meshData, localTransform)  in SystemAPI.Query<RefRW<MeshInstanceData>, RefRW<LocalToWorld>>())
        {
            quaternion rotation = math.mul(meshData.ValueRW.Rotation, quaternion.RotateY(10f * deltaTime));
            float3 position = meshData.ValueRW.Position;
            position += new float3(math.sin(time) * 0.1);
            meshData.ValueRW.Position = position;
            meshData.ValueRW.Rotation = rotation;
            localTransform.ValueRW.Value = float4x4.TRS(meshData.ValueRW.Position, rotation, meshData.ValueRW.Scale);
        }
    }
}

OnCreateのタイミングで、どんなデータが必要かを通知できる

コードを見てみると OnCreate のタイミングで state.RequireForUpdate<T>() を実行しているのが分かります。これは、このシステムが要求するデータを示しています。そのため、現在のワールド内に該当のコンポーネントを持っているEntityがない場合は OnUpdate がスキップされます。

クエリで取得したデータを加工する

OnUpdate 内では SystemAPI を通してコンポーネントをクエリして取得し、取得されたコンポーネントに対して更新処理を行っている様子です。

このシステムの OnUpdate 内でまとめてデータを処理するために、メモリアクセス的に効率よく処理が行えている、というわけですね。さらにアンマネージドなコンポーネントを使っている場合は [BurstCompile] 属性を付与することでさらに高速に処理することができるようになります。

EntityはID

最後にECSの「E」であるEntityについてです。

Entity についてはあまり書くことがないと書いたのは、 Entity は実質ただの ID でしかないからです。

実際に実装内容を見てみると、実質的には IndexVersion しかありません。(実際には IComparable<T> などのインターフェースを実装しているためメソッドは定義されていますが、前述のふたつの値を比較するなどの目的なので本質的には無視して問題ない内容となります)

以下は実際のコードから変数部分だけを抜き出したものです。

public struct Entity : IEquatable<Entity>, IComparable<Entity>
{
    /// <summary>
    /// The ID of an entity.
    /// </summary>
    /// <value>The index into the internal list of entities.</value>
    /// <remarks>
    /// Entity indexes are recycled when an entity is destroyed. When an entity is destroyed, the
    /// EntityManager increments the version identifier. To represent the same entity, both the Index and the
    /// Version fields of the Entity object must match. If the Index is the same, but the Version is different,
    /// then the entity has been recycled.
    /// </remarks>
    public int Index;
    /// <summary>
    /// The generational version of the entity.
    /// </summary>
    /// <remarks>The Version number can, theoretically, overflow and wrap around within the lifetime of an
    /// application. For this reason, you cannot assume that an Entity instance with a larger Version is a more
    /// recent incarnation of the entity than one with a smaller Version (and the same Index).</remarks>
    /// <value>Used to determine whether this Entity object still identifies an existing entity.</value>
    public int Version;
}

Index が実質的にIDになっており、このIDを利用してコンポーネントのデータを取得したり、ということが内部的に行われているわけです。

言い換えると、 MonoBehaviourGameObject のように、オブジェクト自身がデータを持っているわけではない、ということさえ理解しておけば大丈夫です。

まとめ

ECSがなぜ高速に動くのか、その理由が分かったかと思います。

大事な点を再掲すると、

  1. メモリ効率が大事
  2. メモリレイアウトを工夫することで最適化
  3. データ単位で処理を行う「データ指向」アーキテクチャ

となります。

ざっくり言ってしまえば、効率的に管理できるようにデータをまとめて用意し、さらに効率的に処理できるようにシステムがまとめてデータを加工する、という流れを実現しているのがECSということができるでしょう。

さらに、本来は色々な制約があって実現するのがむずかしいBurstコンパイラ向けの設定が、仕組みに沿って実装するだけで簡単に実現できるというのも大きなポイントでしょう。

大量にオブジェクトを処理する必要があるようなプロジェクトの場合はぜひ導入を検討してみてください。

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# から利用する方法について書きたいと思います。