e.blog

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

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

f:id:edo_m18:20220227112537p:plain

概要

前回の記事前々回の記事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 チャンクのデータ部を結合したひとつのバイト配列が画像データを表すデータとなります。

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

f:id:edo_m18:20220213230255p:plain

ここの 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化する例を考えます。

f:id:edo_m18:20220220105343p:plain

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

f:id:edo_m18:20220220105140p:plain

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

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

f:id:edo_m18:20220220155122p:plain

元のデータの並びが上段、それを計算したのが下段です。上記例では左のピクセルからの差分を取ってそれを保持しています。つまり計算自体は右から行うわけですね。そして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 の場合は左隣のピクセルからの差分データが並びます。値の算出は以下のようにして左隣のデータを参考にして求めて格納します。

f:id:edo_m18:20220220105115p:plain

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

ピクセルの値 + 左隣のピクセルの値)% 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 の左からの差分の計算をそのまま上からのピクセルの差分に意味を置き換えたものになります。図にすると以下のようになります。(基本的に左隣のものと対象となるピクセルが違うだけで処理そのものは同じです)

f:id:edo_m18:20220220113042p:plain

復元する場合は 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 では平均の値を元に計算を行う、ということです。

f:id:edo_m18:20220220140225p:plain

これを復元するには

ピクセルの値 + 左と上のピクセル値の平均) % 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対応とかもしたい)

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

PNGのText ChunkにC#でデータを書き込む

概要

PNG画像自体に情報を埋め込めたら便利かなーと思ってPNGのテキスト領域について調べたのでそのメモです。

テクスチャを EncodeToPNG()PNGデータ化したあとに、テキスト領域を追加してファイルに書き出し、それを読み込んでパースして表示、というところまでをやります。

今回実装したものはGitHubにあげてあるので、実際の挙動を見たい方はそちらをご覧ください。

github.com



PNGデータの構造

まずはPNGデータの構造について知らないと始まらないのでそのあたりについてまとめます。

PNGのデータ構造はシグネチャチャンクのふたつに分けることができます。そしてチャンクは複数種類あり、必須となる IHDR チャンク(ヘッダ)、IDAT チャンク(データ)、IEND チャンク(フッタ)は必ず含まれます。今回はテキストエリアを示す tEXt チャンクを新たに作り、それを埋め込んでみます。

チャンクの種類などは以下のサイトが参考になります。

www.setsuki.com

大まかにデータ構造を図にすると以下のようになります。

f:id:edo_m18:20220213230255p:plain

シグネチャ

シグネチャは、そのファイルがPNGファイルであることを示すものです。ファイルの先頭の8バイトにそれを示す情報が書き込まれているので、それをチェックすることでPNG画像かどうかを判別することができます。

具体的にはファイルの先頭8バイトのデータが 89 50 4E 47 0D 0A 1A 0A という並びになっています。

こちらの記事によると以下のような意味のようです。

PNGであること
その画像ファイルがPNGであることは、ファイル先頭の8バイトを読めばわかります。 JPEGではFF D8の2バイトから始まりますが、PNGファイルではファイルの先頭に8バイトの 89 50 4E 47 0D 0A 1A 0A が存在するようです。文字列にすると \x89PNG\r\n\x1a\n こうなります。 先頭の \x89 は非ASCII文字で、この非ASCII文字からファイルが始まることでテキストファイルとの区別を付けられるようにしているそうです。また、7bit目をクリアする不正なファイル転送を検知できたり、\r\n などが含まれているのも改行コードを勝手に変換されてしまうのを検知するためだそうです。

こういう、バイナリの構造を知ると発見があって面白いですね。

チャンクの構造

チャンクは複数種類ありますが基本的な構造はすべて同じになっています。

オフセット(サイズ) 名称 内容
0x0000 (4) Length Chunk Dataのサイズ
0x0004 (4) Chunk Type Chunk Typeを示す4文字
0x0008 (n) Chunk Data チャンクのデータ
0x---- (4) CRC (Cyclic Redundancy Check) 整合性チェックのためのCRC

最初の4バイトにデータのサイズ、続く4バイトにチャンクの種類を示す4文字の文字列が入ります。そして Length によって定義された分だけデータが続き、最後にCRCの値(詳細は後述)で終わります。

どこに埋め込むか

さて、PNGデータの構造が分かったところで、実際に tEXt チャンクをどこに入れるかを考えます。PNGデータの構造としてシグネチャの次に必ず IHDR チャンクが来ます。その後は任意のチャンクが続きます。なのでシグネチャ + IHDR チャンクの次に決め打ちで tEXt チャンクを埋め込みます。

IHDRチャンクの構造

IHDR チャンクの構造も確認しておきましょう。

オフセット(サイズ) 名称 内容
0x0000 (4) Length Chunk Dataのサイズで、IHDRは常に 13
0x0004 (4) Chunk Type 常に 0x49 0x48 0x44 0x52
ASCIIコードで "IHDR" となる
0x0008 (4) Chunk Data 画像の幅
0x000C (4) 画像の高さ
0x0010 (1) ビット深度
有効な値は1, 2, 4, 8, 16
0x0011 (1) カラータイプ
1 - パレット使用
2 - カラー
4 - αチャンネル
0x0012 (1) 圧縮手法
0x0013 (1) フィルター手法
0x0014 (1) インターレース手法
0x0015 (4) CRC (Cyclic Redundancy Check) 整合性チェックのためのCRC

挿入位置はIndex 33から

ヘッダのデータサイズは常に 13 なので、Length + Chunk Type + Chunk Data + CRC の合計は 4 + 4 + 13 + 4 = 25 となります。そしてシグネチャ8 を足して 33 バイトがシグネチャとヘッダを合わせたバイトサイズとなります。なので、インデックス 33 から tEXt チャンクを挿入してやればいいことになりますね。

tEXtチャンク構造

次に、tEXt チャンクの構造を確認しましょう。

オフセット(サイズ) 名称 内容
0x0000 (4) Length Chunk Dataのサイズ
0x0004 (4) Chunk Type 常に 0x74 0x45 0x58 0x74
ASCIIコードで "tEXt" となる
0x0008 (1~79) Chunk Data キーワード
0x---- (1) 常に 0
0x---- (n) テキスト文字列
指定しない( 0 )ことも可。文字コードは**Latin-1 [IOS/IEC-8859-1]を使用
0x0015 (4) CRC (Cyclic Redundancy Check) 整合性チェックのためのCRC

キーワードは79バイト以内なら任意で指定できます。一般的なキーワードは以下。

キーワード 解釈
Title 画像のタイトル
Short (one line) title or caption for image
Author 作者の名前
Description 画像の説明
Copyright 著作権の通知
Creation Time 画像の作成日時
Software 作成に使用したソフト
Disclaimer 公的な使用の拒否について
Warning 注意事項
Source 画像の作成に用いたもの
Comment 雑多なコメント、例)GIFコメントからの転換

C#で埋め込みの実装

情報がそろったので実際に実装するコードを見ていきましょう。まず最初は tEXt チャンクデータを生成する部分です。

private Encoding _latin1 = Encoding.GetEncoding(28591);

private byte[] CreateTextChunkData()
{
    // `tEXt` はASCIIエンコーディング
    byte[] chunkTypeData = Encoding.ASCII.GetBytes("tEXt");

    // keywordはLatin1エンコーディング
    byte[] keywordData = _latin1.GetBytes("Comment");

    // 区切り用の `0` を配列で確保
    byte[] separatorData = new byte[] { 0 };

    // data部分はLatin1エンコーディング
    byte[] textData = _latin1.GetBytes(_embedText);

    int headerSize = sizeof(byte) * (chunkTypeData.Length + sizeof(int));
    int footerSize = sizeof(byte) * 4; // CRC
    int chunkDataSize = keywordData.Length + separatorData.Length + textData.Length;

    // チャンクデータ部分を生成
    byte[] chunkData = new byte[chunkDataSize];
    Array.Copy(keywordData, 0, chunkData, 0, keywordData.Length);
    Array.Copy(separatorData, 0, chunkData, keywordData.Length, separatorData.Length);
    Array.Copy(textData, 0, chunkData, keywordData.Length + separatorData.Length, textData.Length);

    // Length用データ
    byte[] lengthData = BitConverter.GetBytes(chunkDataSize);

    // CRCを計算(※)
    uint crc = Crc32.Hash(0, chunkTypeData);
    crc = Crc32.Hash(crc, chunkData);
    byte[] crcData = BitConverter.GetBytes(crc);

    // 全体のデータを確保
    byte[] data = new byte[headerSize + chunkDataSize + footerSize];

    // LengthとCRCはビッグエンディアンにする必要があるのかReverseする必要がある(※)
    Array.Reverse(lengthData);
    Array.Reverse(crcData);

    Array.Copy(lengthData, 0, data, 0, lengthData.Length);
    Array.Copy(chunkTypeData, 0, data, lengthData.Length, chunkTypeData.Length);
    Array.Copy(chunkData, 0, data, lengthData.Length + chunkTypeData.Length, chunkData.Length);
    Array.Copy(crcData, 0, data, lengthData.Length + chunkTypeData.Length + chunkData.Length, crcData.Length);

    return data;
}

細かい点についてはコード内のコメントをご覧ください。以下、※印の部分について説明します。

LengthとCRCはビッグエンディアンにする

これが仕様なのか分かりませんが、LengthCRC はビッグエンディアンにする必要があるようです。ここは利用している圧縮に関係がありそう。以下の記事にそのあたりについて言及があるので詳細はそちらをご覧ください。

darkcrowcorvus.hatenablog.jp

CRCの計算

CRCは巡回冗長検査と呼ばれ、Cyclic Redundancy Checkの頭文字を取ったものです。Wikipediaによると、

誤り検出符号の一種で、主にデータ転送などに伴う偶発的な誤りの検出によく使われている。送信側は定められた生成多項式で除算した余りを検査データとして付加して送信し、受信側で同じ生成多項式を使用してデータを除算し、その余りを比較照合することによって受信データの誤り・破損を検出する。

と書かれており、要はデータの破損チェックです。

CRCの計算はチャンクタイプとチャンクデータを利用する

PNGデータのCRCはチャンクタイプとチャンクデータを用いて計算を行います。コードでは以下の部分です。

uint crc = Crc32.Hash(0, chunkTypeData);
crc = Crc32.Hash(crc, chunkData);
byte[] crcData = BitConverter.GetBytes(crc);

まず、ChunkDataCRCを求め、さらにそれと ChunkDataCRCを求めます。そしてチャンクの最後にこのデータを付け足します。

Crc32 の実装は以下のようになっています。

public static class Crc32
{
    private static uint[] _crcTable = MakeCrcTable();

    private static uint[] MakeCrcTable()
    {
        uint[] a = new uint[256];

        for (uint i = 0; i < a.Length; ++i)
        {
            uint c = i;
            for (int j = 0; j < 8; ++j)
            {
                c = ((c & 1) != 0) ? (0xedb88320 ^ (c >> 1)) : (c >> 1);
            }

            a[i] = c;
        }

        return a;
    }

    private static uint Calculate(uint crc, byte[] buffer)
    {
        uint c = crc;

        for (int i = 0; i < buffer.Length; ++i)
        {
            c = _crcTable[(c ^ buffer[i]) & 0xff] ^ (c >> 8);
        }

        return c;
    }

    public static uint Hash(uint crc, byte[] buffer)
    {
        crc ^= 0xffffffff;
        return Calculate(crc, buffer) ^ 0xffffffff;
    }
}

PNGデータにテキストを埋め込む

前段までで tEXt チャンクのデータの準備が出来ました。あとはこれをデータに埋め込んでやれば完成です。実際に埋め込んでいる部分のコードを抜粋します。

byte[] data = tex.EncodeToPNG();
byte[] chunkData = CreateTextChunkData();

int embeddedDataSize = data.Length + chunkData.Length;
byte[] embeddedData = new byte[embeddedDataSize];

// Copy the PNG header to the result.
Array.Copy(data, 0, embeddedData, 0, PngParser.PngHeaderSize);

// Add a tEXT chunk.
Array.Copy(chunkData, 0, embeddedData, PngParser.PngHeaderSize, chunkData.Length);

// Join the data chunks to the result.
Array.Copy(data, PngParser.PngHeaderSize, embeddedData, PngParser.PngHeaderSize + chunkData.Length, data.Length - PngParser.PngHeaderSize);

File.WriteAllBytes(FilePath, embeddedData);

まず最初に、tex.EncodeToPNG() によってテクスチャをPNGデータ化します。そして前述したように、シグネチャとヘッダの位置の次の位置からデータを埋め込みます。PngParser.PngHeaderSize は定数の 33 です。

新しいデータ用に作成した embeddedDataシグネチャとヘッダ部分をコピーし、その後にテクストチャンクデータを挿入します。そして最後に、元のデータの後半部分を連結して完成です。

まとめ

気軽な気持ちで始めたテキスト埋め込みの実装ですが、PNGデータの仕様にとても詳しくなりました。ちなみに仕様に沿って埋め込んでいるので当然、UnityのAPIである Texture2D.LoadImage(byte[] data) で読み込んでも正常にテクスチャをロードすることができます。

ただこの LoadImage、処理が重いので Texture2D.LoadRawTextureData で読み込ませたくなったので、PNGデータの展開も自作してみました。次回はPNGデータの展開についてもまとめようと思います。

参考にした記事

qiita.com www.engineer-log.com light11.hatenadiary.com

NDI SDKをUnity向けにPlugin化し映像を配信する

f:id:edo_m18:20220205121623p:plain

概要

以前、UnityでNDI SDKを利用して映像を受信するプラグインを作る記事を書きました。

今回はこれを拡張して映像配信部分の実装について書きたいと思います。具体的には、配信映像の準備とそのエンコード、およびDLLの利用法についてまとめます。

なお、今回はAndroidからNDIを利用して映像を配信する部分を解説します。NDIを利用する上で相手側のデバイスの検索や受信については以前の記事に書いたのでここでは割愛します。

※ データのエンコードなどについてはKeijiroさんのKlak NDIを参考にさせていただきました。

実際に動かしているところは以下のような感じです。ちょっと分かりづらいですが、Android側の映像をPCに転送している動画です。



はじめに

コード量はそこまで多くないので、まずは NDISender クラスの全体を見てみましょう。

using System;
using System.Collections;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.UI;

namespace NDIPlugin
{
    public class NDISender : MonoBehaviour
    {
        [SerializeField] private string _ndiName;
        [SerializeField] private ComputeShader _encodeCompute;
        [SerializeField] private bool _enableAlpha = false;
        [SerializeField] private GameObject _frameTextureSourceContainer;
        [SerializeField] private int _frameRateNumerator = 30000;
        [SerializeField] private int _frameRateDenominator = 1001;

        [SerializeField] private RawImage _preview;

        private IFrameTextureSource _frameTextureSource;
        private IntPtr _sendInstance;
        private FormatConverter _formatConverter;
        private int _width;
        private int _height;

        private NativeArray<byte>? _nativeArray;
        private byte[] _bytes;

        private void Start()
        {
            WifiManager.Instance.SetupNetwork();

            if (!NDIlib.Initialize())
            {
                Debug.Log("NDIlib can't be initialized.");
                return;
            }

            _frameTextureSource = _frameTextureSourceContainer.GetComponent<IFrameTextureSource>();

            _formatConverter = new FormatConverter(_encodeCompute);

            IntPtr nname = Marshal.StringToHGlobalAnsi(_ndiName);
            NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };
            _sendInstance = NDIlib.send_create(sendSettings);
            Marshal.FreeHGlobal(nname);

            if (_sendInstance == IntPtr.Zero)
            {
                Debug.LogError("NDI can't create a send instance.");
                return;
            }

            StartCoroutine(CaptureCoroutine());
        }

        private void OnDestroy()
        {
            ReleaseInternalObjects();
        }

        private void ReleaseInternalObjects()
        {
            if (_sendInstance != IntPtr.Zero)
            {
                NDIlib.send_destroy(_sendInstance);
                _sendInstance = IntPtr.Zero;
            }

            if (_nativeArray != null)
            {
                _nativeArray.Value.Dispose();
                _nativeArray = null;
            }
        }

        private IEnumerator CaptureCoroutine()
        {
            for (var eof = new WaitForEndOfFrame(); true;)
            {
                yield return eof;

                ComputeBuffer converted = Capture();
                if (converted == null)
                {
                    continue;
                }

                Send(converted);
            }
        }

        private ComputeBuffer Capture()
        {
// #if !UNITY_EDITOR && UNITY_ANDROID
//             bool vflip = true;
// #else
//             bool vflip = false;
// #endif
            bool vflip = true;
            if (!_frameTextureSource.IsReady) return null;

            Texture texture = _frameTextureSource.GetTexture();
            _preview.texture = texture;

            _width = texture.width;
            _height = texture.height;

            ComputeBuffer converted = _formatConverter.Encode(texture, _enableAlpha, vflip);

            return converted;
        }

        private unsafe void Send(ComputeBuffer buffer)
        {
            if (_nativeArray == null)
            {
                int length = Utils.FrameDataCount(_width, _height, _enableAlpha) * 4;
                _nativeArray = new NativeArray<byte>(length, Allocator.Persistent);

                _bytes = new byte[length];
            }

            buffer.GetData(_bytes);
            _nativeArray.Value.CopyFrom(_bytes);

            void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

            // Data size verification
            if (_nativeArray.Value.Length / sizeof(uint) != Utils.FrameDataCount(_width, _height, _enableAlpha))
            {
                return;
            }

            // Frame data setup
            var frame = new NDIlib.video_frame_v2_t
            {
                xres = _width,
                yres = _height,
                line_stride_in_bytes = _width * 2,
                frame_rate_N = _frameRateNumerator,
                frame_rate_D = _frameRateDenominator,
                FourCC = _enableAlpha ? NDIlib.FourCC_type_e.FourCC_type_UYVA : NDIlib.FourCC_type_e.FourCC_type_UYVY,
                frame_format_type = NDIlib.frame_format_type_e.frame_format_type_progressive,
                p_data = (IntPtr)pdata,
                p_metadata = IntPtr.Zero,
            };

            // Send via NDI
            NDIlib.send_send_video_async_v2(_sendInstance, frame);
        }
    }
}

全体のフロー

まずは全体のフローを概観してから詳細を見ていきましょう。まず大きくは以下のフローで映像配信を行っています。

  1. NDIの初期化
  2. NDIのSenderインスタンスの生成
  3. 画面のキャプチャ
  4. キャプチャした画面の変換
  5. 映像の配信(Send)

という流れになります。特に、3~5を毎フレーム実行することでゲーム画面を配信し続けることになります。

NDIの初期化

まずはNDIの初期化を行います。具体的には以下の部分です。

WifiManager.Instance.SetupNetwork();

if (!NDIlib.Initialize())
{
    Debug.Log("NDIlib can't be initialized.");
    return;
}

最初の行の Wifimanager については後述します。初期化はライブラリの初期化メソッドを呼ぶだけです。初期化に失敗した場合 false が返るのでチェックしています。

WifiManagerの作成

冒頭で行っている処理はこの WifiManager をセットアップすることです。これを行わないとAndroidでは正常に動作しないため、Androidの場合にのみ実行するようにしています。

公式ドキュメントにこう書かれており、ネットワーク内のNDIデバイスを検知するのに必要なようなので作成します。

Because Android handles discovery differently than other NDI platforms, some additional work is needed. The NDI library requires use of the “NsdManager” from Android and, unfortunately, there is no way for a third-party library to do this on its own. As long as an NDI sender, finder, or receiver is instantiated, an instance of the NsdManager will need to exist to ensure that Android’s Network Service Discovery Manager is running and available to NDI. This is normally done by adding the following code to the beginning of your long running activities: At some point before creating an NDI sender, finder, or receiver, instantiate the NsdManager: You will also need to ensure that your application has configured to have the correct privileges required for this functionality to operate.

以下のようにして servicediscovery オブジェクトを取得する必要があり、この SetupNetwork メソッドを、NDIの各種機能を使う前に呼び出すことで無事にNDIが利用できるようになります。

この処理はこちらの記事を参考にさせていただきました。

namespace NDISample
{
    public class WifiManager
    {
#if UNITY_ANDROID && !UNITY_EDITOR
        private AndroidJavaObject _nsdManager;
#endif

        private static WifiManager _instance = new WifiManager();

        public static WifiManager Instance => _instance;

        private WifiManager()
        {
        }

        public void SetupNetwork()
        {
            // The NDI SDK for Android uses NsdManager to search for NDI video sources on the local network.
            // So we need to create and maintain an instance of NSDManager before performing Find, Send and Recv operations.
#if UNITY_ANDROID && !UNITY_EDITOR
            using (AndroidJavaObject activity = new AndroidJavaObject("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity"))
            {
                using (AndroidJavaObject context = activity.Call<AndroidJavaObject>("getApplicationContext"))
                {
                    using (AndroidJavaObject nsdManager = context.Call<AndroidJavaObject>("getSystemService", "servicediscovery"))
                    {
                        _nsdManager = nsdManager;
                    }
                }
            }
#endif
        }
    }
}

NDI Senderインスタンスの生成

次に行うのはNDIライブラリが提供してくれているSenderのインスタンスを生成することです。コードを抜き出すと以下の部分です。

IntPtr nname = Marshal.StringToHGlobalAnsi(_ndiName);
NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };
_sendInstance = NDIlib.send_create(sendSettings);
Marshal.FreeHGlobal(nname);

if (_sendInstance == IntPtr.Zero)
{
    Debug.LogError("NDI can't create a send instance.");
    return;
}

_ndiNameC#string 型なのでC++側に渡せるように変換します。

ドキュメントから説明を引用すると、

マネージド String の内容をアンマネージド メモリにコピーし、コピー時に ANSI 形式に変換します。

とあるので、マネージド領域の文字列をC++側で使える領域メモリにコピーしてくれるわけですね。

NDI Senderの生成には以下の構造体を渡す必要があるため、それを生成しているのが以下の箇所です。

NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };

また SendSetting の定義は以下のようになっています。

[StructLayout(LayoutKind.Sequential)]
public struct SendSettings
{
    public IntPtr NdiName;
    public IntPtr Groups;
    [MarshalAs(UnmanagedType.U1)] public bool ClockVideo;
    [MarshalAs(UnmanagedType.U1)] public bool ClockAudio;
}

必須となるのは NdiName だけのようなので、今回はそれを指定してNDI Senderのインスタンスを生成しています。

画面のキャプチャ

上記までで初期化と準備が整ったので、ここからは実際にキャプチャを実行し映像を配信する部分を見ていきます。

画面キャプチャのためのコルーチンを起動

まず最初に、フレームの最後の状態をキャプチャするためコルーチンを起動します。

// コルーチンの起動
StartCoroutine(CaptureCoroutine());

// フレームの最後に画面をキャプチャし、それを変換、送信する
private IEnumerator CaptureCoroutine()
{
    for (var eof = new WaitForEndOfFrame(); true;)
    {
        yield return eof;

        ComputeBuffer converted = Capture();
        if (converted == null)
        {
            continue;
        }

        Send(converted);
    }
}

画面キャプチャ処理

実際に画面のキャプチャを行っているのは以下の部分になります。

private ComputeBuffer Capture()
{
// #if !UNITY_EDITOR && UNITY_ANDROID
//             bool vflip = true;
// #else
//             bool vflip = false;
// #endif
    bool vflip = true;
    if (!_frameTextureSource.IsReady) return null;

    Texture texture = _frameTextureSource.GetTexture();
    _preview.texture = texture;

    _width = texture.width;
    _height = texture.height;

    ComputeBuffer converted = _formatConverter.Encode(texture, _enableAlpha, vflip);

    return converted;
}

Texture texture = _frameTextureSource.GetTexture(); の部分が送信するテクスチャを取得している処理です。ここはインターフェースになっていて、画面のキャプチャ以外にも、Webカメラの映像を配信する、というような使い方もできるようにしてあります。(今回は画面をキャプチャした RenderTexture が渡ってきています)

そして得られたテクスチャをコンバータに渡してNDIに送信できるデータに変換します。

テクスチャをNDIに適合した形に変換する

変換を行っている処理は以下の部分になります。変換結果は ComputeBuffer として返されます。

public ComputeBuffer Encode(Texture source, bool enableAlpha, bool vflip)
{
    int width = source.width;
    int height = source.height;
    int dataCount = Utils.FrameDataCount(width, height, enableAlpha);

    // Reallocate the output buffer when the output size was changed.
    if (_encoderOutput != null && _encoderOutput.count != dataCount)
    {
        ReleaseBuffers();
    }

    // Output buffer allocation
    if (_encoderOutput == null)
    {
        _encoderOutput = new ComputeBuffer(dataCount, 4);
    }

    // Compute thread dispatching
    int pass = enableAlpha ? 1 : 0;
    _encoderCompute.SetInt("VFlip", vflip ? -1 : 1);
    _encoderCompute.SetTexture(pass, "Source", source);
    _encoderCompute.SetBuffer(pass, "Destination", _encoderOutput);
    _encoderCompute.Dispatch(pass, width / 16, height / 8, 1);

    return _encoderOutput;
}

冒頭ではデータカウントをチェックし、現在確保されているバッファのサイズと違う場合はバッファを生成し直します。(基本的に毎フレーム送信される映像のサイズが変わることはほぼないので、配信情報が変更された、などの特殊ケースの対応と考えるといいでしょう)

カウントの計算処理は以下のようになっています。

public static int FrameDataCount(int width, int height, bool alpha) => width * height * (alpha ? 3 : 2) / 4;

チェック後はコンピュートシェーダにテクスチャを送り、 ComputeBuffer にデータを詰め込みます。その起動処理は以下の部分です。

int pass = enableAlpha ? 1 : 0;
_encoderCompute.SetInt("VFlip", vflip ? -1 : 1);
_encoderCompute.SetTexture(pass, "Source", source);
_encoderCompute.SetBuffer(pass, "Destination", _encoderOutput);
_encoderCompute.Dispatch(pass, width / 16, height / 8, 1);

VFlip は縦の変換が必要な場合に指定します。また、アルファがあるかないかによって起動するカーネルを変更しています。ちょっと分かりづらいですが、ここではアルファが有効な場合はカーネルインデックス 1カーネルを、そうじゃない場合は 0カーネルを起動します。カーネルの実装は以下のようになっています。

// インデックス 0 のカーネル
[numthreads(8, 8, 1)]
void EncodeUYVY(uint2 id : SV_DispatchThreadID)
{
    uint2 sp = id * uint2(2, 1);

    float4 s0 = Source[sp + uint2(0, 0)];
    float4 s1 = Source[sp + uint2(1, 0)];

    float3 yuv1 = RGB2YUV(s0.xyz);
    float3 yuv2 = RGB2YUV(s1.xyz);

    float u = (yuv1.y + yuv2.y) / 2;
    float v = (yuv1.z + yuv2.z) / 2;
    float4 uyvy = float4(u, yuv1.x, v, yuv2.x);

    uint w, h;
    Source.GetDimensions(w, h);

    uint sy = VFlip < 0 ? h - 1 - id.y : id.y;
    Destination[sy * w / 2 + id.x] = PackUYVY(uyvy);
}
// インデックス 1 のカーネル
[numthreads(4, 8, 1)]
void EncodeUYVA(uint2 id : SV_DispatchThreadID)
{
    uint2 sp = id * uint2(4, 1);

    float4 s0 = Source[sp + uint2(0, 0)];
    float4 s1 = Source[sp + uint2(1, 0)];
    float4 s2 = Source[sp + uint2(2, 0)];
    float4 s3 = Source[sp + uint2(3, 0)];

    float3 yuv0 = RGB2YUV(s0.xyz);
    float3 yuv1 = RGB2YUV(s1.xyz);
    float3 yuv2 = RGB2YUV(s2.xyz);
    float3 yuv3 = RGB2YUV(s3.xyz);

    float u01 = (yuv0.y + yuv1.y) / 2;
    float v01 = (yuv0.z + yuv1.z) / 2;

    float u23 = (yuv2.y + yuv3.y) / 2;
    float v23 = (yuv2.z + yuv3.z) / 2;

    float4 uyvy01 = float4(u01, yuv0.x, v01, yuv1.x);
    float4 uyvy23 = float4(u23, yuv2.x, v23, yuv3.x);

    uint w, h;
    Source.GetDimensions(w, h);

    uint sy = VFlip < 0 ? h - 1 - id.y : id.y;
    uint dp1 = sy * w / 2 + id.x * 2;
    uint dp2 = sy * w / 4 + id.x + w * h / 2;

    Destination[dp1 + 0] = PackUYVY(uyvy01);
    Destination[dp1 + 1] = PackUYVY(uyvy23);
    Destination[dp2] = PackAAAA(float4(s0.w, s1.w, s2.w, s3.w));
}

不透明画像はUYVYフォーマットに変換

まずはインデックス 0カーネルから見ていきましょう。0番のカーネルは不透明画像を変換します。変換先フォーマットはUYVYです。詳細は以下の記事が参考になります。

www.argocorp.com

概要を引用させてもらうと

UYVY は基本的には16ビットのカラーフォーマットです。RGBフォーマットとは違い、それにはred,green,blueのような値は含んでおりません。その代わりに輝度と色度を使用します。Yは輝度を表し、U (or Cb) と V (or Cr) が色度を表します。

ということです。

また、データ構造としてやや特殊なフォーマットになっているのでここで少し詳しく見てみましょう。

記事から説明を引用させてもらうと、

// UYVY ではイメージは上から順にで保存されていくので左上のピクセルからバイト0として始まります。
// 4バイト分が隣り合う2つのピクセルの色を表現します。
//
// [ U0 | Y0 | V0 | Y1 ]
//
// Y0 はピクセル0の輝度を、Y1 がピクセル1の輝度を表します。
// U0 と V0 は2つのピクセルの色を表現します。
struct UYVYQuad
{
     BYTE U0;
     BYTE Y0;
     BYTE V0;
     BYTE Y1;
};

と書かれています。つまり、UYVYフォーマットでは2ピクセルを単位としてデータを保存しているということになります。だいぶ特殊なフォーマットに感じますね。

簡単に2ピクセルだけのデータを考えてみましょう。UYVYフォーマットでは隣あうふたつのピクセルを4バイトで表すのでした。2pxのデータということは width = 2height = 1 となります。そして2ピクセルに対して4バイトのデータを扱うことになるのでその倍率は 2 です。そして4バイトでひとつ分のデータとなるので、データ数(data count)を計算する場合はそれを4バイトの 4 で割ることで得られます。

これが、 width * height * (alpha ? 3 : 2) / 4; の計算式の正体です。(不透明、つまり alphafalse のときは 2 倍されている)

半透明画像はUYVAフォーマットに変換

半透明の場合のフォーマットがドキュメントに載っていなかったので、あくまで実装コードからの推測になりますが、UYVAの場合は、4ピクセルに対して12バイトのデータを扱います。つまり倍率は 3 です。アルファ付きの場合に 3 倍になっているのはここから来ています。

保存している部分を見てみると

Destination[dp1 + 0] = PackUYVY(uyvy01);
Destination[dp1 + 1] = PackUYVY(uyvy23);
Destination[dp2] = PackAAAA(float4(s0.w, s1.w, s2.w, s3.w));

となって、バッファに対して3要素格納しているのが分かります。(ピクセルは4px分取り出しています)

変換の詳細については以下のMicrosoftのドキュメントを参照してください。ここで紹介したフォーマット以外の説明もあります。

docs.microsoft.com

キャプチャした映像を送信する

前段までで画面のキャプチャおよびNDIに送信するための画像フォーマットの変換が完了しました。あとはSDKを利用してこれを送信します。送信部分のコードを再掲するので詳しく見ていきましょう。

private unsafe void Send(ComputeBuffer buffer)
{
    if (_nativeArray == null)
    {
        int length = Utils.FrameDataCount(_width, _height, _enableAlpha) * 4;
        _nativeArray = new NativeArray<byte>(length, Allocator.Persistent);

        _bytes = new byte[length];
    }

    buffer.GetData(_bytes);
    _nativeArray.Value.CopyFrom(_bytes);

    void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

    // Data size verification
    if (_nativeArray.Value.Length / sizeof(uint) != Utils.FrameDataCount(_width, _height, _enableAlpha))
    {
        return;
    }

    // Frame data setup
    var frame = new NDIlib.video_frame_v2_t
    {
        xres = _width,
        yres = _height,
        line_stride_in_bytes = _width * 2,
        frame_rate_N = _frameRateNumerator,
        frame_rate_D = _frameRateDenominator,
        FourCC = _enableAlpha ? NDIlib.FourCC_type_e.FourCC_type_UYVA : NDIlib.FourCC_type_e.FourCC_type_UYVY,
        frame_format_type = NDIlib.frame_format_type_e.frame_format_type_progressive,
        p_data = (IntPtr)pdata,
        p_metadata = IntPtr.Zero,
    };

    // Send via NDI
    NDIlib.send_send_video_async_v2(_sendInstance, frame);
}

冒頭で行っているのは初回のみ NativeArray の領域確保です。そのあとの処理が、バッファからデータを読み出している箇所になります。

buffer.GetData(_bytes);
_nativeArray.Value.CopyFrom(_bytes);

void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

ComputeBuffer から NativeArray へ変換しています。そして最後の部分は NativeArray のポインタを取得しています。SDKC++で書かれており、アンマネージド領域で該当データを扱う必要があるためポインタを取得しているわけです。

そして取得したデータに誤りがないか(要素数に問題がないか)確認をした後、 NDIlib.video_frame_v2_t 構造体に必要な情報を詰めて送信を実行しています。

この構造体を生成する際に、アルファの有無やサイズなどと一緒に、前述の NativeArray のポインタを設定しています。

そして最後に NDIlib.send_send_video_async_v2() メソッドからネイティブ実装を呼び出し、生成した構造体を引数に送信を行っています。

第一引数に NDI Senderインスタンスを渡しているのは、C#側ではC++クラスのインスタンスをダイレクトに生成することができず、ポインタという形でしか保持できないためDLLを利用したネイティブ実装側のクラスのメソッド呼び出しはこういう形になるのが一般的です。

これで無事、映像の送信をすることができました。

LiDARセンサーのデータを使ってUnityで点群を描画する

概要

今回はiPadiPhoneに搭載されているLiDARセンサーから得られる深度情報を使って、立体的に点群位置を計算する方法について書いていきます。

具体的な動作は以下の動画をご覧ください。画面中央に表示されているのが、センサーから得られた情報を元に点の位置を計算しそれを可視化している点群です。背景はカメラが映している実際のシーンです。この計算方法についてまとめます。

GitHub

また今回の実装はGitHubにもアップしてあるので、実際に動くものが見たい方はそちらをご覧ください。

github.com



実装解説

今回の実装にあたり、TokyoYoshidaさんこちらのリポジトリの実装と、KeijiroさんRcam2のリポジトリを参考にさせていただきました。

実装概要

まずは大まかな流れから見ていきましょう。

利用するデータ

LiDARセンサー付きiPad or iPhoneから以下のデータを取得します。

  • 深度データ
  • RGBデータ
  • カメラデータ

深度データ

深度データとは、以下のような深度値を格納したテクスチャのことです。これによって対象までの距離を知ることが出来ます。(以下の画像はUnityのカメラで撮影したものですが、基本的な概念は変わりません)

f:id:edo_m18:20220127152145p:plain

RGBデータ

RGBデータはそのままカラーの映像テクスチャです。言い換えればカメラ画像です。こちらは説明不要ですね。

カメラデータ

最後のカメラデータは、以下で説明するカメラ自体のデータのことです。具体的にはカメラの焦点距離解像度などです。

データから点群の座標を計算

細かい説明に入る前に位置を計算しているコードを概観してみましょう。

uint2 gridPoint = id.xy * _GridPointsScale;
float2 uv = float2(id.xy) / _DepthResolution;

float depth = _DepthMap.SampleLevel(_LinearClamp, uv, 0).x * 1000.0;
float xrw = (float(gridPoint.x) - _IntrinsicsVector.z) * depth / _IntrinsicsVector.x;
float yrw = (float(gridPoint.y) - _IntrinsicsVector.w) * depth / _IntrinsicsVector.y;
float3 prw = float3(xrw, yrw, depth);

float s = 0.001;
float4 pos = mul(float4x4(
    s, 0, 0, 0,
    0, s, 0, 0,
    0, 0, s, 0,
    0, 0, 0, 1
    ), float4(prw, 1.0));
pos = mul(_TransformMatrix, float4(pos.xyz, 1.0));

最後に求めた pos の値が点群ひとつの位置です。これを必要数分並べたのが冒頭の点群表示というわけです。位置の計算だけに関して言えば20行に満たないコードです。順を追って見ていきましょう。

点群のX, Y位置を求める

uint2 gridPoint = id.xy * _GridPointsScale;

id.xy は起動されたスレッドの X, Y 位置、言い換えると(今回の場合は)テクスチャの X, Y 位置と考えてもらって差し支えありません。その位置に対して _GridPointScale を掛けています。これがなにかと言うと、撮影されたカメラの解像度に比べて深度マップの解像度が異なるためです。そのためカメラ解像度と深度マップの比率を元に位置を合わせる必要があるわけです。(逆に言えば、解像度が合っていればこの計算は不要ということになります)

コンピュートシェーダに値を送っているところの計算を見てみると以下のようになります。

_pointCloudParticle.GridPointsScale = new Vector4(
    (float)metadata.cameraResolution.x / (float)metadata.depthResolution.x,
    (float)metadata.cameraResolution.y / (float)metadata.depthResolution.y,
    0, 0);

カメラ自体の解像度を深度マップの解像度で割った値を設定しているのが分かるかと思います。

なぜこれをしているかというと、サイズが異なるということは、言い換えれば X, Y のスケールと Z のスケールとが合わなくなってしまうということです。結果、点群の位置がおかしくなってしまうわけです。そのためにスケールを合わせているわけなんですね。

なぜX, Yを求めるのか? Zのスケールが合わないって?

ここで、なぜ X, Y を求める必要があるのかですが、これはピンホールカメラの仕組みを理解すると分かります。以下のスライドがとても分かりやすく解説してくれているので詳細はそちらをご覧ください。

www.slideshare.net

ここでは、中の画像を引用させてもらいつつなぜ計算が必要かについて説明します。

f:id:edo_m18:20211028123813p:plain

原点となっているのがピンホールカメラで言うところのホール部分です。そして右側の青い三角形がリアルな被写体までの関係を表し、左側の緑の三角形がカメラの画像センサー素子までの関係を表しています。

言い換えると、青い三角形のリアル被写体を、緑の三角形側の画像に転写していることを示しているわけです。そしてセンサー側の素子がカメラ解像度と一致します。

深度マップの場合、保存されているのは深度情報です。上の図で言うと Z の値ということですね。

そして前述の通り、カメラの解像度と深度マップのサイズが合っていないため、そのまま利用してしまうと上図での X, Y, Z のうち X, Y の値が(スケールが)異なってしまうわけですね。そのための補正が前述の話になります。言い換えると、Z の値に変化がないのに X, Y の値だけ変化してしまっているわけですね。

カメラデータの中身を紐解く

ARFoundationから得られるカメラデータをもう少し詳しく見てみましょう。まず最初に、得られるデータは以下になります。

// ARCameraManagerのインスタンスから情報を取得する
ARCameraManager.TryGetIntrinsics(out XRCameraIntrinsics intrinsics);

得られるデータ( XRCameraIntrinsics )は以下です。

[StructLayout(LayoutKind.Sequential)]
public struct XRCameraIntrinsics : IEquatable<XRCameraIntrinsics>
{
    mmary>
    /// The focal length in pixels.
    /// </summary>
    /// <value>
    /// The focal length in pixels.
    /// </value>
    /// <remarks>
    /// The focal length is the distance between the camera's pinhole and the image plane.
    /// In a pinhole camera, the x and y values would be the same, but these can vary for
    /// real cameras.
    /// </remarks>
    public Vector2 focalLength => m_FocalLength;

    /// <summary>
    /// The principal point from the top-left corner of the image, expressed in pixels.
    /// </summary>
    /// <value>
    /// The principal point from the top-left corner of the image, expressed in pixels.
    /// </value>
    /// <remarks>
    /// The principal point is the point of intersection between the image plane and a line perpendicular to the
    /// image plane passing through the camera's pinhole.
    /// </remarks>
    public Vector2 principalPoint => m_PrincipalPoint;

    /// <summary>
    /// The dimensions of the image in pixels.
    /// </summary>
    public Vector2Int resolution => m_Resolution;

    // 後略
}

主に注目すべきは focalLengthprincipalPoint の2点です。 focalLength は日本語に訳すと「焦点距離」、すなわち前述の焦点距離を意味しています。そして他方、 principalPoint はコードにも説明が載っているので引用すると、

The principal point is the point of intersection between the image plane and a line perpendicular to the image plane passing through the camera's pinhole.

principalPoint は、イメージ平面、つまり先ほどの原点を含む平面と、ピンホールを通過する、画像平面と垂直な線との交点ということになります。

前述のスライドから引用させていただくと、次に示す画像が分かりやすいでしょう。

f:id:edo_m18:20220126232719p:plain f:id:edo_m18:20220127140406p:plain

つまり、画像中心と光学中心のオフセット、というわけです。

深度マップに保存されているデータから点群位置を復元

さて、上記までで画像から点群位置を復元するための情報が集まりました。具体的に言うと、画像位置の X, Y および保存されている Z の値を用いて、ピクセルの、現実空間での位置を求めるための道具が揃ったということです。

実は冒頭で掲載した復元のための計算は、ここで求めた行列の「逆行列を掛けて」いることと考えることができます。もう一度、該当部分を抜き出してみましょう。

uint2 gridPoint = id.xy * _GridPointsScale;
float2 uv = float2(id.xy) / _DepthResolution;

float depth = _DepthMap.SampleLevel(_LinearClamp, uv, 0).x * 1000.0;
float xrw = (float(gridPoint.x) - _IntrinsicsVector.z) * depth / _IntrinsicsVector.x;
float yrw = (float(gridPoint.y) - _IntrinsicsVector.w) * depth / _IntrinsicsVector.y;

uv は前述の x, y の値を求めていると言い換えることができます。そしてその下の計算部分がまさに「逆行列を掛けている」と考えられる部分です。ただ、実際に行列を利用しているわけではありません。なぜなら、そもそも逆行列の意味は、対象行列に対して掛けることで単位行列にするもの、言い換えれば「行列の作用をなかったことにするもの」と言えます。つまり、「逆変換」をしてあげればいいわけですね。幸い、今回の行列はシンプルです。まず普通に行列を適用すると以下のようになります。( Z は画像での λ

Zx = f * X
x = f * X / Z

x は上記での uv.x です。ここで求めたいのは X の値ですね。なので上記式を変形すると、

X = x * Z / f

上のプログラムと形が似ていることが分かるかと思います。補足しておくと _IntrinsicsVector.xy には focalLength が、 _IntrinsicsVector.zw には principalPoint が格納されています。プログラムで float(gridPoint.x) - _IntrinsicsVector.z) となっているのは引用させていただいたスライドで書かれている「オフセット」を考慮したものです。細かいことを抜きに考えれば上の式と同じ形になっていることが分かるかと思います。

実は点群の位置復元のための計算は以上となります。仕組みが分かってしまえば計算自体はまったくむずかしいところはありません。あとは求めた位置をパーティクルに与え、対象のパーティクルの色をRGB映像からサンプリングしてやれば無事、冒頭の動画のように点群を復元することができるというわけです。

まとめ

各データがなにを意味し、それをどう取り扱えばいいかが分かれば計算自体は複雑ではないことが分かったかと思います。結局のところ、RGBのデータもX, Yのデータも、そして深度も、すべてテクスチャになっていることが理解できれば、あとはそこからデータを取り出し簡単な計算をするだけで済みます。あとはここから様々なエフェクトを考えることもできるでしょう。ただ、これを通信で送ろうとすると少しだけ問題が出てきます。精度の問題です。テクスチャに保存されているデータの精度を落とさずに通信相手に届ける必要があるため、そこは少し工夫が必要となります。とはいえ、通信相手側での計算ロジックは変わらないので、通信部分さえなんとかなれば遠隔で点群を復元することも可能でしょう。

再掲となりますが、実際に動作するコードはGitHubにアップしてあります。

github.com

UnityでGraphQL.Clientを使ってAWS AppSyncを利用する

この記事はUnity Advent Calendar 2021の11日目の記事です。

概要

今回はNuGetからインストールできるGraphQL.Clientを利用して、AWSのAppSyncを利用する手順を書いていこうと思います。

特にSubscriptionを行うには手順が必要でこれを知るのにかなり苦戦しました。

今回のサンプルはGitHubにアップしてあります。

github.com



GraphQLとは

GraphQLについては以下の記事がとても参考になりました。

eh-career.com

上記記事から引用させていただくと以下のように説明されています。

まずGraphQLとは何でしょうか。GraphQLは、Facebookが開発しているWeb APIのための規格で、「クエリ言語」と「スキーマ言語」からなります。

クエリ言語は、GraphQL APIのリクエストのための言語で、これはさらにデータ取得系のquery、データ更新系のmutation、サーバーサイドからのイベントの通知であるsubscriptionの3種類があります。なお、この記事では、総称としてのクエリ言語は「クエリ言語」と書き、クエリの3種のひとつであるqueryは「query」と書くことにします。


スキーマ言語は、GraphQL APIの仕様を記述するための言語です。リクエストされたクエリは、スキーマ言語で記述したスキーマに従ってGraphQL処理系により実行されて、レスポンスを生成します。


GraphQLは、クエリがレスポンスデータの構造と似ていて情報量が多いこと、そしてスキーマによる型付けにより型安全な運用ができることが特徴となっています。

これを自分の言葉で解説すると、

APIスキーマ言語によって定義し、それに則したクエリを実行することでサーバ側の任意の処理を実行することができる。

という感じでしょうか。そして基本的にAPIとして必要なものは「データ取得」「データ更新」「特定処理の実行」なので、それらを querymutationに分けていると考えるといいと思います。また最近では、サーバ側のデータが更新されたことをリアルタイムに知りたい場合があり、これを実現するのが subscription ということになります。

引用した文章と同様、本記事でもクエリ言語クエリ、それぞれの個別の処理は querymutationsubscription と記載することにします。

詳細については上記記事など有用な記事がたくさんあるのでそちらに譲ります。ここでは、冒頭のクライアントを利用してUnity上でそれぞれのクエリが適切に実行できる状態まで実装した内容をまとめていきたいと思います。

AWS AppSyncの設定

まずはAWSのAppSync側からセットアップを行っていきます。今回はAWS側で用意してくれている「サンプルプロジェクトから開始する」を選択して開始します。順番に手順を見ていきましょう。

APIを作成

まずはAWSAppSyncコンソールを開きます。そしてAppSyncコンソール上で「APIを作成」ボタンを押下してプロジェクトを作成します。

f:id:edo_m18:20211114215532p:plain

サンプルプロジェクトの「イベントアプリ」を選択して「開始」を押します。

f:id:edo_m18:20211114215707p:plain

しばらく待つとDynamoDBの作成が終わり、AppSyncの操作画面に遷移します。ここではスキーマの定義やクエリのテストなどが行えるようになっています。

f:id:edo_m18:20211114215837p:plain

スキーマを見てみる

今回は「イベントアプリ」というサンプルプロジェクトなので、イベントを管理するためのアプリを想定したスキーマが最初から定義されています。主な型として Event があります。以下のような形で定義されています。

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int = 10, nextToken: String): CommentConnection
}

Event typeは ID 型である必須のフィールド id と、名前や場所などの情報が含まれていることが確認できます。

クエリを見てみる

次に、テストできるクエリを見てみましょう。

コンソールの左側の「クエリ」からクエリテストビューを開くと、デフォルトでいくつかのクエリが設定されています。そのうち、イベントのリストを取得する query は以下のようになっています。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

これは ListEvents という query 名で、 Query としてスキーマに登録されている型の中で定義されている listEvents を呼び出していることを示しています。(ちなみに Query は以下のように定義されています)

type Query {
    # Get a single event by id.
    getEvent(id: ID!): Event
    # Paginate through events.
    listEvents(filter: TableEventFilterInput, limit: Int = 10, nextToken: String): EventConnection
}

schema {
    query: Query
    mutation: Mutation
    subscription: Subscription
}

この定義からも分かるように、 listEvents を実行した場合のレスポンスは EventConnection 型となっています。これも見てみると、

type EventConnection {
    items: [Event]
    nextToken: String
}

となっており、 Event のリストが返ることが分かります。

スキーマを定義しそれを呼び出す

以上のように、ほしい情報、ほしいアクションをスキーマとして定義し、それを querymutationsubscription という形で呼び出す、というのが一連の流れとなります。またとても大事な点として、リクエストしたクエリがほぼそのままの構造でレスポンスとして返ってくるという点です。

どういうことかと言うと、 GraphQL ではスキーマに定義されているものであれば形を省略しても問題ないようになっています。例えば例の Query は以下のようになっています。

type Query {
    // 中略
    listEvents(filter: TableEventFilterInput, limit: Int, nextToken: String): EventConnection
}

type EventConnection {
    items: [Event]
    nextToken: String
}

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int, nextToken: String): CommentConnection
}

そして listEvents の戻りの型は EventConnection になっており、その中身は items という名前の配列で、中身は Event ということが分かります。つまり、最終的には Event のリストを取得するという、名前通りの振る舞いをするわけです。そして Event にはいくつかのパラメータがあります。(ex. name

しかし、クエリを実行する際はこれをすべて指定する必要はなく、冒頭で紹介したように「ほしいパラメータだけ」を指定してクエリを実行することができます。例を再掲すると以下。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

するとサーバからは、 items という配列に Eventidname だけが含まれたデータが返されます。言い換えるとリクエストからレスポンスの型を想像することができるとも言えます。

これがREST APIだと、どういうレスポンスが返ってくるかドキュメントを読まないと分かりません。しかし、 GraphQL ならリクエストからレスポンスが想像できるため実装が容易になります。これはとても大きなメリットです。

Unityによる実装

さてでは実際にUnityで実装していきましょう。Unityによる実装はこちらの記事を参考にさせていただきました。

qiita.com

NuGetをインストール

今回はNuGetで管理されている GraphQL.Client を利用して実装を行うため、まずはNuGetをインストールします。インストールは以下のパッケージをインポートすることで行なえます。

github.com

NuGetForUnityがインポートできたらWindowメニューにNuGetが追加されるので「Manage NuGet Package」をクリックしてGraphQLを検索、インストールします。

f:id:edo_m18:20211128234941p:plain

今回必要なパッケージは以下の2つです。

  • GraphQL.Client
  • GraphQL.Client.Serializer.Newtonsoft

f:id:edo_m18:20211128234852p:plain

queryを実行する

まずは query の実行から見てみましょう。

public async void SendQuery()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest query = new GraphQLRequest
    {
        Query = _queryInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

    Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
}

実行はさほど長くありませんね。以下から、どういうことをやっているのか見ていきましょう。

GraphQLHttpClientを作成

まずは GraphQLHttpClient オブジェクトを生成し、リクエスト先のURLやヘッダを適切に設定します。

GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

GraphQLRequestを作成

次に、クエリとなる GraphQLRequest オブジェクトを生成します。今回はデータ取得の query です。_queryInputField.text にはUIに自由に query を書けるようにしているので、それを指定しています。

GraphQLRequest query = new GraphQLRequest
{
    Query = _queryInputField.text,
};

リクエストを投げてレスポンスを得る

最後に、生成したクエリを送信してレスポンスを受け取ります。

var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");

これを実行すると以下のようにレスポンスを受け取ることができます。

f:id:edo_m18:20211210114119p:plain

レスポンスを受け取る型の定義

GraphQLのレスポンスを受け取るためには、専用のクラスを定義する必要があります。

SendQueryAsync メソッドの戻り値は GraphQLResponse<T> となっていて、この T が、取得したいレスポンスの型を定義したclass になります。GraphQLResponse<T> クラスには T 型の値が設定された Data というプロパティがあるので、これを経由して実際に取得した値にアクセスします。

ちなみにサンプルの例では以下のようにクラスを定義しています。

public class EventType
{
    public string id { get; set; }
    public string name { get; set; }
    public string where { get; set; }
    public string when { get; set; }
    public string description { get; set; }
}

public class QueryResponse
{
    public EventType getEvent { get; set; }
}

スキーマに定義しているものと型、名称が一致しているのが分かるかと思います。ちなみに注意点として、関数のように呼び出した場合、関数名がパラメータ名として返ってくるので QueryResponse では getEvent という名前のフィールドを定義しています。

実際のqueryとレスポンスの例は以下です。

query MyQuery {
  getEvent(id: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    id
    name
  }
}
{
  "data": {
    "getEvent": {
      "id": "c49ceb83-17f5-43b3-a511-98c3721841d2",
      "name": "My First Event"
    }
  }
}

mutationを実行する

さて、次は更新を伴う mutation です。といっても、こちらは query と大差ありません。さっとコードを見てみましょう。

public async void SendMutation()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _mutationInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

    Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
}

Query に渡しているテキスト以外、まったく同じことが分かります。ちなみに mutation は以下のようになります。( query 同様、更新したい内容だけを含めることができます)

mutation MyMutation {
  commentOnEvent(content: "hogehoge", createdAt: "2021/11/10", eventId: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    commentId
    content
    createdAt
    eventId
  }
}

取得・更新の流れはなんとなく分かったかと思います。次はリアルタイムに値の更新を受け取る方法です。

subscriptionでリアルタイム通知を受け取る

実は今回一番書きたかったのはここですw

これを実現するのにだいぶ苦戦しました。なぜかと言うと、他のリクエストに比べていくつかの前処理が必要になるからです。

まずは処理の流れをざっと見てみましょう。

public async void AddSubscription()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

    AppSyncHeader appSyncHeader = new AppSyncHeader
    {
        Host = _host,
        ApiKey = _apiKey,
    };

    string header = appSyncHeader.ToBase64String();

    graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
    graphQLClient.Options.PreprocessRequest = (req, client) =>
    {
        GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
        {
            ["data"] = JsonConvert.SerializeObject(req),
            ["extensions"] = new
            {
                authorization = appSyncHeader,
            }
        };
        return Task.FromResult(result);
    };

    await graphQLClient.InitializeWebsocketConnection();

    Debug.Log("Initialized a web scoket connection.");

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _subscriptionInputField.text,
    };

    var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
    _subscription = subscriptionStream.Subscribe(
        response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
        exception => Debug.Log(exception),
        () => Debug.Log("Completed."));
}

リアルタイムな通知はWebSocketを利用する

サーバの変更をリアルタイムに受け取るための通信は WebSocket を使っています。そのため、通常のクエリを送るエンドポイントとは別に、WebSocketのエンドポイントを設定する必要があります。その設定をしているのが以下です。

// WebSocket向けのエンドポイントを設定する
graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");

// ... 中略 ...

// WebSocketのコネクションを初期化する
await graphQLClient.InitializeWebsocketConnection();

PreprocessRequestを用いてヘッダを調整する

さて、話はこれだけでは終わりません。

どうやらAWSのAppSyncは少し特殊な仕様になっているらしく、以下のように、リクエスト前にヘッダを調整しないとならないようです。

// AuthorizedAppSyncHttpRequestクラスの定義
public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
{
    private readonly string _authorization;

    public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
        => _authorization = authorization;

    public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
    {
        HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
        result.Headers.Add("X-Api-Key", _authorization);
        return result;
    }
}


// 上記クラスを実際に使うところ
graphQLClient.Options.PreprocessRequest = (req, client) =>
{
    GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
    {
        ["data"] = JsonConvert.SerializeObject(req),
        ["extensions"] = new
        {
            authorization = appSyncHeader,
        }
    };
    return Task.FromResult(result);
};

具体的になにをしているかと言うと、リクエストヘッダに対して data にリクエスト自体をJSON化したものを、そして extensionsX-Api-Key をキーにAuthorizationトークンを指定したデータを付与しています。これを付与することで、AppSyncに対して正常にSubscriptionできるようになります。

あとは通常の手順通りSubscriptionの処理を実装するのみです。

Subscriptionする

最後、Subscriptionの処理を見てみましょう。

GraphQLRequest request = new GraphQLRequest
{
    Query = _subscriptionInputField.text,
};

var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
_subscription = subscriptionStream.Subscribe(
    response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
    exception => Debug.Log(exception),
    () => Debug.Log("Completed."));

クエリを指定する部分はQueryやMutationと代わりありませんね。そして最後の部分。 CreateSubscriptionStream を生成しているのが相違点です。リアルタイム通信なのでストリームなわけですね。

ストリームを作成したら、そのストリームに対して Subscribe してその結果を受け取ります。

実際に動かし、AppSyncのコンソールから mutation を実行した結果がこちらです↓

f:id:edo_m18:20211209181444p:plain

Subscriptionのログが表示され、更新された内容が出力されているのが確認できます。これで無事、リアルタイムに更新を受け取ることができるようになりました。

コード全文

最後に、そんなに長くはないので今回のサンプルコードを全部載せておきます。

using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GraphQL;
using UnityEngine;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.Newtonsoft;
using Newtonsoft.Json;
using GraphQL.Client.Abstractions;
using TMPro;

public class GraphQLHelloWorld : MonoBehaviour
{
    [SerializeField] private string _host = "example12345.appsync-api.us-east-2.amazonaws.com";
    [SerializeField] private string _realtimeHost = "example12345.appsync-realtime-api.us-east-2.amazonaws.com";
    [SerializeField] private string _apiKey = "YOUR_API_KEY_HERE";

    [SerializeField] private TMP_InputField _queryInputField;
    [SerializeField] private TMP_InputField _mutationInputField;
    [SerializeField] private TMP_InputField _subscriptionInputField;
        
    public class EventType
    {
        public string id { get; set; }
        public string name { get; set; }
        public string where { get; set; }
        public string when { get; set; }
        public string description { get; set; }
    }

    public class CommentType
    {
        public string eventId { get; set; }
        public string commentId { get; set; }
        public string content { get; set; }
        public string createdAt { get; set; }
    }

    public class QueryResponse
    {
        public EventType getEvent { get; set; }
    }

    public class CreateMutationResponse
    {
        public EventType createEvent { get; set; }
    }

    public class CreateCommentResponse
    {
        public CommentType commentOnEvent { get; set; }
    }

    public class SubscriptionResponse
    {
        public CommentType subscribeToEventComments { get; set; }
    }

    private void OnDestroy()
    {
        _subscription?.Dispose();
    }

    private class AppSyncHeader
    {
        [JsonProperty("host")] public string Host { get; set; }

        [JsonProperty("x-api-key")] public string ApiKey { get; set; }

        public string ToJson()
        {
            return JsonConvert.SerializeObject(this);
        }

        public string ToBase64String()
        {
            return Convert.ToBase64String(Encoding.UTF8.GetBytes(ToJson()));
        }
    }

    public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
    {
        private readonly string _authorization;

        public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
            => _authorization = authorization;

        public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
        {
            HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
            result.Headers.Add("X-Api-Key", _authorization);
            return result;
        }
    }

    private IDisposable _subscription;

    public async void OnClickQuery()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest query = new GraphQLRequest
        {
            Query = _queryInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

        Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickMutation()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _mutationInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

        Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickSubscription()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

        AppSyncHeader appSyncHeader = new AppSyncHeader
        {
            Host = _host,
            ApiKey = _apiKey,
        };

        string header = appSyncHeader.ToBase64String();

        graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
        graphQLClient.Options.PreprocessRequest = (req, client) =>
        {
            GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
            {
                ["data"] = JsonConvert.SerializeObject(req),
                ["extensions"] = new
                {
                    authorization = appSyncHeader,
                }
            };
            return Task.FromResult(result);
        };

        await graphQLClient.InitializeWebsocketConnection();

        Debug.Log("Initialized a web scoket connection.");

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _subscriptionInputField.text,
        };

        var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
        _subscription = subscriptionStream.Subscribe(
            response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
            exception => Debug.Log(exception),
            () => Debug.Log("Completed."));
    }
}

ハマったポイント

ここでは、今回のサンプル制作を通していくつかハマった点について調べた記事などを残しておこうと思います。

Subscriptionのリアルタイム更新が動かない

前述のように、subscription の実装に苦戦しました。調べたところAppSyncの実装がその他のGraphQLの実装とやや異なることが問題と分かりました。それでも情報があまりにも少なく、仕方なく開発中のGitHubのissueに質問を投げてみました。

github.com

すると以下のように回答があり、サンプルコードを公開してくれている人のリンクを教えてもらいました。

I've not worked with AppSync myself, but @bjorg created a sample app which might help you along:

https://github.com/bjorg/GraphQlAppSyncTest

実際にAppSyncに対応しているコードがあるのはこちらです。

github.com

こちらの実装を参考にしなんとか無事、subscription を利用してリアルタイムにデータを受け取ることができるようになりました。

実際のコードについては本文で示した通りです。

Subscriptionの実行でエラーになる

AWS AppSyncのコンソールには、手軽にQueryを試せるビューがあります。そこで Subscription を実行した際、エラーが表示される場合があります。これは Subscription 以外がQueryに並んでいると起きるようなので、以下のようにそれ以外を消してから実行するとうまく行きました。

f:id:edo_m18:20211114133126p:plain

Unity Androidのネイティブプラグイン(C++)でOpenGLのテクスチャデータをNativeArrayにコピーする

概要

以前、iOSのネイティブプラグイン側でテクスチャを保存するという記事を書きました。

edom18.hateblo.jp

今回はこれと似た、Androidのネイティブプラグインを作成してテクスチャをコピーする方法について書きたいと思います。

今回の実装にあたり、こちらの記事を大いに参考にさせていただきました。ありがとうございます。

qiita.com

今回実装したものの動作はこちら。(地味すぎてよく分かりませんがw)

実際のプロジェクトは以下にアップしてあります。

github.com



低レベルネイティブプラグインプラグインインターフェース

低レベルネイティブプラグインインターフェースは、Unityが用意してくれているネイティブプラグインを実装する際に利用できる機能です。この手順に沿って実装することで、各種Graphics APIなどの情報にアクセスすることができるようになります。

docs.unity3d.com

凹みさんも過去に記事を公開してくれているので、これ自体に興味がある方は見てみるとより詳しく知ることができると思います。

tips.hecomi.com

必要な関数を公開して情報を受け取る

インターフェース、と名前が付いている通り、必要な関数を定義し公開することで、Unity側で適切にデータを受け渡してくれる、というものです。具体的には以下のようにプラグイン側を実装することで、対象プラットフォームのGraphics APIなどに簡単にアクセスすることができるようになります。

static IUnityInterfaces* s_UnityInterfaces = NULL;
static IUnityGraphics* s_Graphics = NULL;

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces)
{
    LOG_PRINTF("Called a load callback.");

    s_UnityInterfaces = unityInterfaces;
    s_Graphics = s_UnityInterfaces->Get<IUnityGraphics>();
    s_Graphics->RegisterDeviceEventCallback(OnGraphicsDeviceEvent);

    OnGraphicsDeviceEvent(kUnityGfxDeviceEventInitialize);
}

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload()
{
    s_Graphics->UnregisterDeviceEventCallback(OnGraphicsDeviceEvent);
}

IUnityGraphics インターフェースのヘッダファイルの場所

インターフェースの定義は、例えば以下のような場所に保存されているためそれをコピーして利用します。

C:\Program Files\Unity\Hub\Editor\2019.4.14f1\Editor\Data\PluginAPI

Android Studioプロジェクトを用意

まずはAndroid Studioプロジェクトを用意します。プロジェクト作成はUnityを利用します。

UnityからAndroidStudio用プロジェクトの作成

以下の手順でAndroid Studioプロジェクトを作成します。

  • Unityを起動しプロジェクトを作成する(例: AndroidPlugin )
  • AndroidにスイッチしバンドルIDを設定する(例: com.example.androidplugin
  • ExportProjectDevelopmentBuildをチェックしExportを押す
  • Unityプロジェクトと同じフォルダにExport用フォルダを作成し、フォルダに移動してから「フォルダの選択」を押す(例: AndroidPlugin\Android
  • AndroidStudio用プロジェクトが生成される

f:id:edo_m18:20211106165808p:plain

Android Studioを設定する

前段で作成したAndroid Studioプロジェクトを、プラグイン作成用に設定していきます。

Unityプラグイン用のフォルダに、プラグインとなるファイルを追加していきます。作成したプロジェクトをAndroid Studioで開き、プラグイン名( unityLibrary )内に以下のファイルを配置します。

  • unityLibrary/src/main/ 内に cpp フォルダを作成する
  • cpp フォルダ内に CmakeLists.txt ファイルを作成する(この時点では中身は空でOK)
  • unityLibraryコンテキストメニューから LinkC++ProjectWithGradle を選択してリンクする

f:id:edo_m18:20211106170731p:plain

Android Studio周りのエラー解消

自分の環境ではセットアップ中に色々エラーが出たので解消のために色々調べる必要がありました。これらエラーも環境によって異なると思うので、エラーが出た際の参考にしてください。(出なかった場合はスキップしてください)

ライセンス周りのエラー

以下のようなライセンス周りのエラーが出たのでその解消法です。

Failed to install the following Android SDK packages as some licences have not been accepted.
   ndk-bundle NDK
   patcher;v4 SDK Patch Applier v4
To build this project, accept the SDK license agreements and install the missing components using the Android Studio SDK Manager.
Alternatively, to transfer the license agreements from one workstation to another, see http://d.android.com/r/studio-ui/export-licenses.html

Using Android SDK: C:\Program Files\Unity\Hub\Editor\2019.4.14f1\Editor\Data\PlaybackEngines\AndroidPlayer\SDK

結論から言うと、該当のパッケージなどのライセンスに同意していないというもの。なので、以下の記事を参考に、必要なパッケージをインストールすることでライセンスに同意することができました。

※ ちなみに管理者権限がないと書き込み失敗するので管理者権限でコマンドプロンプトを開始すること。

$ /path/to/AndroidSDK/tools/bin/sdkmanager "patcher;v4"
# 具体的なパス例: C:\Path\To\UnityHub\2019.4.14f1\Editor\Data\PlaybackEngines\AndroidPlayer\SDK\tools\bin\sdkmanager 

qiita.com

qiita.com

NDK周りのエラー

NDK周りのエラーが出ていたのでNDKのフォルダパスを適切に設定して解消しました。

NDK is missing a "platforms" directory.
If you are using NDK, verify the ndk.dir is set to a valid NDK directory.  It is currently set to C:\Path\To\Sdk\ndk-bundle.

設定はウィンドウメニューの File > Project Structure... を開き、 SDK Location 内の Android NDK location に設定します。

f:id:edo_m18:20211106171258p:plain

f:id:edo_m18:20211106171308p:plain

これでNDKの設定でエラーが解消されましたが、その後さらにいくつかのモジュールインストールでコケていたので、Android Stduioを管理者権限で起動することで解決できました。


IUnityGraphics インターフェースの追加

冒頭で書いた、Unityが用意してくれているインターフェースをプロジェクトに追加します。

前述の PluginAPI フォルダを cpp フォルダにコピーし、 Unity とrenameします。

cppファイルの追加

cpp フォルダ内に、プラグインの実装となるcppファイルを追加します。(例: copy-texture-data.cpp

最終的に以下の状態になっていればOKです。

f:id:edo_m18:20211106170348p:plain

CMakeLists.txtを編集

CMakeLists.txt ファイルを編集し、C++プロジェクトをビルドするための準備を行います。具体的には以下のように記載します。(参考にさせていただいた記事から引用させていただいています)

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

cmake_minimum_required(VERSION 3.4.1)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library(# Sets the name of the library.
        native-lib

        # Sets the library as a shared library.
        SHARED

        # Provides a relative path to your source file(s).
        copy-texture-data.cpp)

# Specifies a path to native header files.
include_directories(Unity/)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library(# Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries(# Specifies the target library.
        native-lib

        # Links the target library to the log library
        # included in the NDK.
        ${log-lib}
        GLESv2)

cmakeC/C++プロジェクトをビルドするために利用されるコマンドです。詳細については「cmake cpp build」などで検索してみてください。

プラグインの実装

以上でAndroid Studioの設定が完了しました。ここからは実際にコードを書いてプラグイン部分を作成していきます。

今回は以下のコードを書きました。

#include "IUnityInterface.h"
#include "IUnityGraphics.h"
#include <math.h>
#include <stdio.h>
#include <assert.h>
#include <GLES3/gl3.h>

static GLuint  g_textureId = NULL;
static int     g_texWidth;
static int     g_texHeight;
static void*   g_data = NULL;

static void UNITY_INTERFACE_API OnRenderEvent(int eventID);

#define LOG_PRINTF printf

extern "C" bool SetNativeTexture(void* textureId, void* data, int width, int height)
{
    g_textureId = (GLuint)(size_t)textureId;
    g_data = data;
    g_texWidth = width;
    g_texHeight = height;

    LOG_PRINTF("SetNativeTexture:%d, %d, %d", g_textureId, g_texWidth, g_texHeight);

    return true;
}

extern "C" void FinishNativeTexture()
{
    if (g_data != NULL)
    {
        delete[] g_data;
    }
    g_data = NULL;
}

extern "C" UnityRenderingEvent UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API GetRenderEventFunc()
{
    return OnRenderEvent;
}

static void ReadPixels()
{
    int currentFBOWrite;
    glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &currentFBOWrite);
    glBindFramebuffer(GL_READ_FRAMEBUFFER, currentFBOWrite);

    glReadPixels(0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, g_data);
}

static void UNITY_INTERFACE_API OnRenderEvent(int eventID)
{
    ReadPixels();
}

実装の内容はそこまで多くありません。メインとなる処理に絞って説明していきます。

ネイティブ側にテクスチャポインタおよびデータポインタを渡す

まず最初に見るのが SetNativeTexture 関数です。

extern "C" bool SetNativeTexture(void* textureId, void* data, int width, int height)
{
    g_textureId = (GLuint)(size_t)textureId;
    g_data = data;
    g_texWidth = width;
    g_texHeight = height;

    LOG_PRINTF("SetNativeTexture:%d, %d, %d", g_textureId, g_texWidth, g_texHeight);

    return true;
}

この関数は、C#側からテクスチャポインタとデータポインタが渡され、それを保持します。それぞれ void* 型として渡されます。テクスチャに関してはテクスチャIDに変換して保持しておきます。

g_textureId = (GLuint)(size_t)textureId;

それ以外については static 変数に保持しておきます。

テクスチャからデータを読み出す

次に見るのが ReadPixels 関数です。これが今回のプラグインのメイン部分です。といってもコード量は全然多くありません。

static void ReadPixels()
{
    int currentFBOWrite;
    glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &currentFBOWrite);
    glBindFramebuffer(GL_READ_FRAMEBUFFER, currentFBOWrite);

    glReadPixels(0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, g_data);
}

ここで行っているのは、現在アタッチされているFrameBufferからの値を読み出し、データポインタとして渡された位置への書き込みです。

この関数が呼び出される前にC#側でバッファを設定しているので、ここでは GL_DRAW_FRAMEBUFFER_BINDING されているバッファを取得し、それをバインドしています。

そして glReadPixels 関数を呼び出してデータを読み出しています。

ネイティブ側の関数を呼び出す

最後に見るのは OnRenderEvent 関数と GetRenderEventFunc 関数です。ここで行っているのは、C#側から適切に呼び出せるようにするための定義です。細かい説明よりも、C#側のコードを見たほうが早いと思うので、どう使われているかを見てみましょう。

GL.IssuePluginEvent(GetRenderEventFunc(), 1);

GL.IssuePluginEventUnityが用意しているAPIで、ネイティブコードプラグインにユーザーが定義したイベントを送信します。端的に言えば、プラグインの関数を呼び出します。

GetRenderEventFunc 関数は関数ポインタを返す関数になっています。つまり、ポインタが示す関数を呼び出している、というわけです。

Pluginをビルドする

C++側の実装が終わったらAndroid StudioでPluginをビルドします。以下の Make Project からビルドを実行します。すると unityLibrary 内に build フォルダが生成されます。そして build/intermediates/cmake/debug/obj/armeabi-v7a/libnative-lib.so の場所にファイルが生成されているので、これをUnityの Plugins/Android フォルダに配置します。

f:id:edo_m18:20211106195525p:plain

※ 参考にした記事ではGradleのタスクを追加し、それが自動的に Plugins/Android に配置されるようになっていたのですが、実行タスクとして選択できなかったので今回の例では手動でコピーしました。

以上がプラグイン側の実装になります。次はC#側の実装を見てみましょう。

C#側を実装する(プラグインの機能を呼び出す)

まずはコード全文を載せます。その後に、主要部分について説明します。

using System;
using System.Collections;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.UI;

public class CopyTextureData : MonoBehaviour
{
    [SerializeField] private RawImage _rawImage = null;
    [SerializeField] private RawImage _resultImage = null;
    [SerializeField] private int _width = 512;
    [SerializeField] private int _height = 512;
    [SerializeField] private Camera _camera;

    private RenderTexture _renderTexture;
    private Texture2D _result;
    private NativeArray<byte> _nativeArray;

    [DllImport("copytexturedata")]
    unsafe private static extern bool SetNativeTexture(IntPtr textureId1, void* data, int width, int height);

    [DllImport("copytexturedata")]
    private static extern void FinishNativeTexture();

    [DllImport("copytexturedata")]
    private static extern IntPtr GetRenderEventFunc();

    unsafe private void Start()
    {
        _width = Screen.width;
        _height = Screen.height;
        
        _renderTexture = new RenderTexture(_width, _height, 0, RenderTextureFormat.ARGB32);
        _renderTexture.Create();

        _result = new Texture2D(_width, _height, TextureFormat.RGBA32, false);
        _resultImage.texture = _result;

        _rawImage.texture = _renderTexture;
        _camera.targetTexture = _renderTexture;

        _nativeArray = new NativeArray<byte>(_width * _height * 4, Allocator.Persistent);

        if (!SetNativeTexture(_renderTexture.GetNativeTexturePtr(), _nativeArray.GetUnsafePtr(), _renderTexture.width, _renderTexture.height))
        {
            return;
        }

        StartCoroutine(NativeTextureRenderLoop());
    }

    private void OnDestroy()
    {
        _renderTexture.Release();
        _nativeArray.Dispose();
        FinishNativeTexture();
    }

    private void IssueEvent()
    {
        RenderTexture back = RenderTexture.active;
        RenderTexture.active = _renderTexture;
        GL.IssuePluginEvent(GetRenderEventFunc(), 1);
        RenderTexture.active = back;

        _result.SetPixelData(_nativeArray, 0, 0);
        _result.Apply();
    }

    private IEnumerator NativeTextureRenderLoop()
    {
        while (true)
        {
            yield return new WaitForEndOfFrame();
            IssueEvent();
        }
    }
}

プラグイン側の関数の利用を宣言

プラグインの機能を呼び出すには以下のように宣言します。

[DllImport("copytexturedata")]
unsafe private static extern bool SetNativeTexture(IntPtr textureId1, void* data, int width, int height);

[DllImport("copytexturedata")]
private static extern void FinishNativeTexture();

[DllImport("copytexturedata")]
private static extern IntPtr GetRenderEventFunc();

なお、 DllImport については過去に(英語ですが)記事を書いているので興味がある方は見てみてください。

edom18.medium.com

定義を見てもらうと分かりますが unsafe がついているため、プロジェクトの設定かAssembly Definition Fileなどで unsafe なコードが書けるように設定する必要があります。

ポインタをネイティブ側に渡す

以下のコードが、ネイティブ側にポインタを渡している箇所です。

_nativeArray = new NativeArray<byte>(_width * _height * 4, Allocator.Persistent);
if (!SetNativeTexture(_renderTexture.GetNativeTexturePtr(), _nativeArray.GetUnsafePtr(), _renderTexture.width, _renderTexture.height))
{
    return;
}

Texture クラスには GetNativeTexturePtr() メソッドがあるのでこれを、また NativeArray<T> には GetUnsafePtr() メソッドがあるのでこれを利用してポインタを渡しています。

なお、なぜ NativeArray<T> を使っているかというと、 glReadPixels 関数を実行すると、マネージドメモリの場合にInvalidエラーが出てしまうためです。そのため、 NativeArray<T> を使ってアンマネージドなメモリを確保し、そのポインタを渡しているというわけです。

ネイティブ実装を呼び出してデータをコピーする

最後に、ネイティブ側の実装を呼び出しているところを見てみましょう。

private void IssueEvent()
{
    RenderTexture back = RenderTexture.active;
    RenderTexture.active = _renderTexture;
    GL.IssuePluginEvent(GetRenderEventFunc(), 1);
    RenderTexture.active = back;

    _result.SetPixelData(_nativeArray, 0, 0);
    _result.Apply();
}

上記メソッドを毎フレーム呼び出すことで、冒頭の動画のように、 RenderTexture の内容をコピーしています。

ネイティブ実装側でも書きましたが、 RenderTexture.active に呼び出したい RenderTexture を設定してネイティブ実装を呼び出しています。こうすることでテクスチャからのデータを読み出しているというわけです。

後半部分は実際にデータコピーが成功しているかを示すためのデバッグ処理です。 NativeArray<T> のデータをテクスチャに適用することで確認を行っています。

その他の機能

ちょっと余談として、ネイティブ側でテクスチャ自体にデータを設定する方法を備忘録として残しておきます。

今回の実装ではテクスチャの内容を配列にコピーするというものでしたが、以下はテクスチャ自体に配列のデータを適用するというものです。

glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, bytes);

なお、 bytesu_char* 型の配列データです。