e.blog

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

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

概要

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

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

github.com

動作サンプル

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

github.com



パススルーの設定

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

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

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

edom18.hateblo.jp

パススルーを有効化する

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

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

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

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

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


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


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


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

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

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

OVRSceneManager をシーンに配置する

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

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

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

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


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

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

平面用Prefab Plane Prefab

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

ボリューム用Prefab Volume Prefab

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

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

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

Prefabの構成

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

Plane Prefabの構成

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

Volume Prefabの構成

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

Prefab Overridesに設定するPrefabの構成

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

マテリアルについて

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

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

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

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

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

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

まとめ

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

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

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

概要

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

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

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

github.com



全体のフロー

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

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

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

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

サブシステムの設定

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

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

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

Project Settingsに項目を表示する

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

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

XRConfigurationDataAttribute

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

using UnityEngine;
using UnityEngine.XR.Management;

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

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

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

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

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

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

  • IXRPackage
  • IXRPackageMetadata
  • IXRLoaderMetadata

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

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

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

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

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

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

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

        return true;
    }

    public IXRPackageMetadata metadata => s_metaData;
}

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

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

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

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

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


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

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


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

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

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

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

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

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

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

Loaderの実装

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

Loader実装の予備知識

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

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

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

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

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

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


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

初期化処理

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

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

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

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

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

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

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

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

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

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

        return sessionSubsystem != null;
    }
}

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

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

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

// SubsystemDescriptorWithProvider`2.cs

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

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

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

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

となります。

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

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

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

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

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

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

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

登録処理はRuntimeInitializeOnLoadMethodAttributeを使用

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

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

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

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

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

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

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

Loaderの決定

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

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

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

Loaderの処理フロー

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

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

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

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

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

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

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

    activeLoader = null;
}

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

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

サブシステムの実装

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

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

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

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

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

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

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

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

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

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

サブクラスの実装詳細

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

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

github.com

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

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

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

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

            public override Guid sessionId => this.m_sessionId;

            public override Feature currentTrackingMode => Feature.AnyTrackingMode;

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

            public override IntPtr nativePtr => IntPtr.Zero;

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

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

            public override TrackingState trackingState => SessionApi.trackingState;

            public override NotTrackingReason notTrackingReason => NotTrackingReason.None;

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

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

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

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

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

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

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

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

            public override bool keepWaiting => false;

            protected override void OnKeepWaiting() { }
        }

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

            public override bool keepWaiting => false;

            protected override void OnKeepWaiting() { }
        }
    }
}

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

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

という感じでしょうか。

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

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

using UnityEngine.XR.ARSubsystems;

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

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

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

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

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

assetstore.unity.com

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

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

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

サブシステムの利用

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

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

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

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

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

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

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

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

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

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

protected TSubsystem GetActiveSubsystemInstance()
{
    TSubsystem activeSubsystem = null;

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

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

    return activeSubsystem;
}

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

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

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

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

    SubsystemManager.GetSubsystemDescriptors<TDescriptor>(descriptors);

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

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

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

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

最後に

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

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

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

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

概要

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

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

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

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



開発環境

  • Ubntsu 18.04.5 on WSL

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

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

V8 をビルドする準備

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

▼ V8 の公式サイト v8.dev

▼ V8 のビルド手順 v8.dev

ツールをインストール

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

depot_tools をインストール

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

まずは Git から clone します。

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

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

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

depot_tools を update

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

$ gclient

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

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

$ mkdir ~/v8
$ cd ~/v8

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

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

$ fetch v8
$ cd v8

ターゲット OS を設定する

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

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

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

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

Android 用ツールを更新する

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

$ gclient sync

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

ビルドする

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

ファイルを生成する

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

$ tools/dev/v8gen.py arm.release

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

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

$ tools/dev/v8gen.py list

ビルドの設定を調整する

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

$ gn args out.gn/arm.release

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

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

マングリングの問題

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

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

ビルドを実行する

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

$ ninja -C out.gn/arm.release

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

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

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

トラブルシューティング

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

No such file or directory

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

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

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

www.jianshu.com

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

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

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

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

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

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

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

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

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

pkg_config でエラー

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

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

'GLIBCXX_3.4.26' not found

エラー全文は以下です。

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

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

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

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

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

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

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

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

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

最後に

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

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

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

概要

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

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

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

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

github.com

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



C# Job Systemとは

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

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

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

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

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

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

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

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

実装方法

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

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

IJobを実装する

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

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

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

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

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

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

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

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

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

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

  • NativeArray
  • NativeList
  • NativeHashMap
  • NativeMultiHashMap
  • NativeQueue

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

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

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

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

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

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

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

Jobを実行する

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

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

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

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

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

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

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

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

        resultArray.Dispose();
    }
}

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

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

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

Jobを直列につなげる

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

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

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

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

Burst Compilerとは

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

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

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

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

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

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

www.youtube.com

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

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

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

並列化して高速化

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

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

Filter Type 1の行を抜き出す

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        Expand(startIndex, y);
    }

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

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

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

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

            left = Pixel32.CalculateFloor(current, left);

            ptr += metaData.stride;

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

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

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

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

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

後続の処理もIJobで実装

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

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

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

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

    public PngMetaData metaData;

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

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

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

            byte filterType = data[idx];

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

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

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

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

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

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

        Pixel32 current = default;

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

            ptr += metaData.stride;

            *pixelPtr = current;
            ++pixelPtr;
        }
    }

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

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

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

        int upStride = metaData.width;

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

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

            up = Pixel32.CalculateFloor(current, up);

            ptr += metaData.stride;

            *pixelPtr = up;
            ++pixelPtr;
        }
    }

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

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

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

        int upStride = metaData.width;

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

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

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

            ptr += metaData.stride;

            *pixelPtr = left;
            ++pixelPtr;
        }
    }

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

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

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

        int upStride = metaData.width;

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

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

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

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

            ptr += metaData.stride;

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

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

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

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

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

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

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

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

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

    _started = true;
    
    ShowTexture();
}

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

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

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

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

    _preview.texture = texture;

    Dispose();

    _started = false;

    _stopwatch.Stop();

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

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

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

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

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

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

最後に

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

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

概要

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

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

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

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

github.com



PNGのデータ構造

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

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

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

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

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

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

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

データを復元する

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

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

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

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

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

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

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

    const int metaDataSize = 4 + 4 + 4;

    int index = PngSignatureSize + ihdr.length + metaDataSize;

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

    int totalSize = 0;

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

        Chunk chunk = ParseChunk(data, index);

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

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

        index += chunk.length + metaDataSize;
    }

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

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

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

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

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

    return (metaData, decompressed);
}

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

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

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

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

stackoverflow.com

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

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

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

フィルタリングを解く

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

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

darkcrowcorvus.hatenablog.jp

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

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

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

フィルタリングの種類

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

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

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

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

www.webtech.co.jp

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

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

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

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

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

加工

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        int startIndex = idx + 1;

        switch (filterType)
        {
            case 0:
                break;

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

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

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

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

    Texture2D texture = null;

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

    return texture;
}

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

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

byte filterType = data[idx];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        int upStride = metaData.width;

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

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

            up = Pixel32.CalculateFloor(current, up);

            p += stride;

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

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

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

これを復元するには

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

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

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

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

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

        int upStride = metaData.width;

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

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

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

            p += stride;

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

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

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

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

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

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

参考にさせていただいた記事から引用すると、以下のアルゴリズムで値が決定しているもののようです。

Paethアルゴリズムは、左、上、左上の 3つの隣接するピクセル値から、「この位置に来るであろうピクセル値が、上記 3つのピクセル値のうち、どれと一番近くなりそうか」を予測するために利用される。Alan W. Paethさんが考案した

また計算式も引用させていただくと、以下を満たす位置にあるピクセルの値を採用します。

int PaethPredictor(int a, int b, int c)
{
    // +--------+
    // | c | b |
    // +---+---+
    // | a | ? |
    // +---+---+
    int p = a + b - c;

    // pa = |b - c|   横向きの値の変わり具合
    // pb = |a - c|   縦向きの値の変わり具合
    // pc = |b-c + a-c| ↑ふたつの合計
    int pa = abs(p - a);    
    int pb = abs(p - b);    
    int pc = abs(p - c);    

    // 横向きのほうがなだらかな値の変化 → 左
    if (pa <= pb && pa <= pc)
        return a;

    // 縦向きのほうがなだらかな値の変化 → 上
    if (pb <= pc)
        return b;
        
    // 縦横それぞれ正反対に値が変化するため中間色を選択 → 左上        
    return c;
}

これの復元は以下のように行います。

ピクセルの値 + Paethアルゴリズムによって求まったピクセルの値) % 256

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

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

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

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

        int upStride = metaData.width;

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

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

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

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

            p += stride;

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

public static Pixel32 CalculatePaeth(Pixel32 a, Pixel32 b, Pixel32 c, Pixel32 current)
{
    int cr = PaethPredictor(a.r, b.r, c.r);
    int cg = PaethPredictor(a.g, b.g, c.g);
    int cb = PaethPredictor(a.b, b.b, c.b);
    int ca = PaethPredictor(a.a, b.a, c.a);

    return CalculateFloor(current, new Pixel32((byte)cr, (byte)cg, (byte)cb, (byte)ca));
}

private static int PaethPredictor(int a, int b, int c)
{
    int p = a + b - c;
    int pa = Mathf.Abs(p - a);
    int pb = Mathf.Abs(p - b);
    int pc = Mathf.Abs(p - c);

    if (pa <= pb && pa <= pc)
    {
        return a;
    }

    if (pb <= pc)
    {
        return b;
    }

    return c;
}

展開処理の説明は以上です。最後に、この計算で展開されたデータをテクスチャのデータとして渡すことができれば完成です。

構造体の配列を直にバイト配列として読み込む(ポインタからテクスチャを生成)

展開処理の最後はデータを実際のテクスチャデータとして利用することです。ここでは、効率的にテクスチャにデータを渡す方法を見ていきます。

展開処理で見てきたように、計算を簡単にするために Pixel32 という構造体を作りました。計算結果の配列もこの Pixel32 が並んだものになっています。しかし当然、独自で作成した構造体なのでこれをそのままテクスチャのデータとして読み込ませることはできません。

しかし、Texture2D.LoadRawTextureDataポインタを受け取ることができ、メソッド名からも分かる通りテクスチャのピクセルを表すバイト配列を期待しています。なので、Pixel32 の配列をバイト配列として認識させれば引数に渡すことができます。

構造体はシンプルに、フィールドを順番に並べたものになっています。つまり RGBA の4バイトが順番に並び、それが配列になっているので実質Raw dataと見なすことができるわけです。

前置きが長くなりましたがやることはシンプルです。Pixel32 の配列をポインタに変換して、それを引数に渡すことで簡単に実現することができます。百聞は一見にしかずということでコードを見てみましょう。

Texture2D texture = null;
unityContext.Post(s =>
{
    texture = new Texture2D(metaData.width, metaData.height, TextureFormat.RGBA32, false);

    // GCの対象にならないようにハンドルを取得
    GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);

    try
    {
        IntPtr pointer = handle.AddrOfPinnedObject();
        texture.LoadRawTextureData(pointer, metaData.width * metaData.height * 4);
    }
    finally
    {
        if (handle.IsAllocated)
        {
            // GCされるように解放
            handle.Free();
        }
    }

    texture.Apply();
}, null);

while (texture == null)
{
    Thread.Sleep(16);
}

return texture;

pixelsPixel32 構造体の配列です。これの GCHandle を取得し、 AddrOfPinnedObject() メソッドから配列の先頭アドレスを得ます。戻り値は IntPtr なのでこれをそのまま LoadRawTextureData() メソッドに渡すことで読み込むことができます。第2引数はデータのサイズです。

最後に

今回は学習目的での実装だったのでそこまで最適化をしていません。そのため Texture2D.LoadImage で読み込む処理に比べるとだいぶ遅いです。(大体10倍くらい遅い)

ただUnity APIを利用していないのでスレッドで実行できますし、前回の記事のようにテキストチャンクにデータを仕込んでそれを取り出す、ということもできます。ちょっとした付与データ込みの画像データを保存する、とかであれば現状でも用途があるのかなと思っています。

最初に実装したのは配列をそのまま利用していたためさらに処理が重かったのですが、ポインタ経由にすることで3倍くらいは速くなりました。ポインタを利用しての最適化は他の場所でも使えるので覚えておいて損はないかなと思います。

いちおう、特定用途ではありますが実用に耐えうるものとしてより最適化をしていこうと思っています。(できたらBurst対応とかもしたい)

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

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

概要

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

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

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

github.com



PNGデータの構造

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

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

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

www.setsuki.com

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

シグネチャ

シグネチャは、そのファイルが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化し映像を配信する

概要

以前、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を利用したネイティブ実装側のクラスのメソッド呼び出しはこういう形になるのが一般的です。

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