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コンパイラ向けの設定が、仕組みに沿って実装するだけで簡単に実現できるというのも大きなポイントでしょう。

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

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コンテンツを作成する上でかなり大きな意味を持つと思います。ぜひみなさんも面白いアイデアが思いついたら実装してみてください。

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対応とかもしたい)

もし最適化が出来て実用に耐えうるものになったらそれも記事に書こうと思います。