e.blog

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

【XR】URP向けのマルチビュー対応イメージエフェクトシェーダの書き方

概要

前回書いた「URPで背景をぼかしてuGUIの背景にする」で書いたことの続編です。

edom18.hateblo.jp

具体的には、前回の実装のままでVRのマルチビュー(やSingle Pass Instanced)に変更すると正常に描画されないという問題があったのでそれへの対応方法がメインの内容となります。

マルチビューで動いているかどうか伝わらないですが(w)、動作した動画をアップしました。

今回の問題を対処したものはGitHubリポジトリにマージ済みです。

github.com



マルチビューに対応する

イメージエフェクト(ブラー)については前回の記事とほぼ同じです。
それをベースにいくつかの部分をマルチビュー対応していきます。

なお、マルチビューなどのステレオレンダリングについては凹みさんの以下の記事が超絶詳しく解説してくれているので興味がある方はそちらを参考にしてみてください。

tips.hecomi.com

そもそもマルチビューとは

マルチビューとは一言で言うとOpenGLが持つOVR_multiviewという拡張機能です。
VRの場合、両目にレンダリングする必要があるためどうしても処理負荷が高くなりがちです。

そこで様々な方法が考え出されました。(それらについては前述の凹みさんの記事を参照ください)
それに合わせてGPUベンダー側も要望に答える形で新しい機能を追加したりしています。

このOpenGL拡張機能もそうした新機能を使うためのものです。
自分もまだ正確に理解しきれてはいないのですが、VRの両目レンダリングの負荷を下げるために、一度だけレンダリングのコマンドを送信すると、それをよしなに複製して両目分にレンダリングしてくれる、というような機能です。

Oculusのドキュメントから引用すると以下のように説明されています。

マルチビューを有効化すると、オブジェクトは一度左のアイバッファーにレンダリングされた後、頂点位置と視覚依存変数(反射など)に適切な変更が加えられて、自動的に右のバッファーに複製されます。

また、OculusのWebGL版のドキュメントでは以下のように説明されています。

マルチビュー拡張機能では、ドローコールがテクスチャー配列の対応する各エレメントにインスタンス化されます。頂点プログラムは、新しいViewID変数を使用して、ビューごとの値(通常は頂点位置と反射などの視覚依存変数)を計算します。

ドローコールがテクスチャ配列ごと(つまり両目のふたつ)にインスタンス化されることで実現しているようですね。

そしてこれを実現しているのがレンダーターゲットアレイと呼ばれる、レンダーターゲット(ビュー)を配列にしたものです。
なので「マルチビュー」なんですね。

そしてこの「レンダーターゲットアレイ」というのが今回の修正のキモです。
どういうことかと言うと、前回の実装ではレンダーターゲットアレイではなく、あくまで片目用の通常のレンダリングにのみ対応した書き方をしていました。
(マルチパスの場合は片目ずつそれぞれレンダリングしてくれていたので問題にならなかった)

だからマルチビューにした途端に正常に動かなくなっていたというわけです。
しかし、マルチビューかどうかを判定して色々処理を書くのはとても骨が折れます。

そこでUnityは、どちらの設定であっても正しく処理を行えるようにするためのマクロをたくさん用意してくれています。
今回の修正は主に、それらマクロを使ってどう記述したらいいかということの説明になります。

その他、マルチビューについての解説は以下の記事を参照ください。

blogs.unity3d.com

マルチビュー対応マクロを使う

前述のように、それぞれの処理はマクロを使うことでマルチビューでもそうでなくても正常に動作するコードを書くことができます。

それほど長いコードではないので実際に適用済みのコードをまず貼ってしまいましょう。

Shader "Custom/BlurEffect_Adapted"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
    }

    HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

    static const int samplingCount = 10;

    TEXTURE2D_X(_MainTex);
    SAMPLER(sampler_MainTex);
    uniform half4 _Offsets;
    uniform half _Weights[samplingCount];

    struct appdata
    {
        half4 pos : POSITION;
        half2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };

    struct v2f
    {
        half4 pos : SV_POSITION;
        half2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
        UNITY_VERTEX_OUTPUT_STEREO
    };

    v2f vert(appdata v)
    {
        v2f o;

        UNITY_SETUP_INSTANCE_ID(v);
        UNITY_TRANSFER_INSTANCE_ID(v, o);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

        o.pos = mul(unity_MatrixVP, mul(unity_ObjectToWorld, half4(v.pos.xyz, 1.0h)));
        o.uv = UnityStereoTransformScreenSpaceTex(v.uv);

        return o;
    }

    half4 frag(v2f i) : SV_Target
    {
        UNITY_SETUP_INSTANCE_ID(i);
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
        
        half4 col = 0;

        [unroll]
        for (int j = samplingCount - 1; j > 0; j--)
        {
            col += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv - (_Offsets.xy * j)) * _Weights[j];
        }

        [unroll]
        for (int k = 0; k < samplingCount; k++)
        {
            col += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv + (_Offsets.xy * k)) * _Weights[k];
        }

        half3 grad1 = half3(1.0, 0.95, 0.98);
        half3 grad2 = half3(0.95, 0.95, 1.0);
        half3 grad = lerp(grad1, grad2, i.uv.y);

        col.rgb *= grad;
        col *= 1.15;

        return col;
    }
    ENDHLSL

    SubShader
    {
        Pass
        {
            ZTest Off
            ZWrite Off
            Cull Back
            
            Fog
            {
                Mode Off
            }

            HLSLPROGRAM
            #pragma fragmentoption ARB_precision_hint_fastest
            #pragma vertex vert
            #pragma fragment frag
            ENDHLSL
        }
    }
}

ビルトインのレンダーパイプライン向けにイメージエフェクトを書かれたことがある人であればちょっとした違いに気付くかと思います。

まず大きな違いはHLSLで記述することです。なのでCGPROGRAMではなくHLSLPROGRAMで始まっているのが分かるかと思います。

そしてこれから紹介するマクロは以下の.hlslファイルに定義されています。

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

これをインクルードすることで以下のマクロたちが使えるようになります。

マクロを使ってテクスチャを宣言する

ではさっそく上から見ていきましょう。
まずはテクスチャの宣言です。

前述したように、マルチビューでない場合は通常のテクスチャで、マルチビューの場合は配列として処理を行う必要があります。
ということで、それを設定に応じてよしなにしてくれるマクロを使って書くと以下のようになります。

TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);

TEXTURE2D_X_Xが次元を表していると考えると覚えやすいかと思います。
そして以前は必要なかったサンプラの宣言も合わせて行っています。

修正前は以下のようになっていました。

sampler2D _MainTex;

マクロを使ってテクスチャからフェッチする

次はテクスチャの使い方です。

まず、UVの座標空間が若干異なるため、それを変換するための関数を実行して変換してやります。

// そのままフラグメントシェーダに渡すのではなく、関数を通して変換する
o.uv = UnityStereoTransformScreenSpaceTex(v.uv);

テクスチャフェッチは以下のようにマクロを使います。

half4 col = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv);

使う場合も同様にSAMPLE_TEXTURE2D_X_Xがついていますね。
そして第2引数にサンプラを指定します。それ以外の引数は普段見るものと違いはありません。

ビューIDを適切に取り扱う

実は上記マクロだけでは正常にレンダリングされません。

というのも、マルチビューは配列を利用して処理を最適化するものだと説明しました。
配列ということは「どちらのテクスチャにアクセスしたらいいか」という情報がなければなりません。

そしてそのセットアップはまた別のマクロを使って行います。
セットアップは構造体の宣言に手を加え、適切に初期化を行う必要があります。

ビューIDを追加するマクロ

新しくなった構造体の宣言は以下のようになります。

struct appdata
{
    half4 pos : POSITION;
    half2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
    half4 pos : SV_POSITION;
    half2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

appdata、つまり頂点シェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_IDを追加し、v2f、つまりフラグメントシェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_IDUNITY_VERTEX_OUTPUT_STEREOのふたつを追加します。

マクロの中身については後述しますが、こうすることで適切にインデックスを渡すことができるようになります。

ビューIDの初期化

続いてシェーダ関数内で値を適切に初期化します。具体的には以下のようにマクロを追加します。

v2f vert(appdata v)
{
    v2f o;

    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_TRANSFER_INSTANCE_ID(v, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    // 以下省略
}

頂点シェーダ関数の冒頭でマクロを利用して初期化を行います。
次はフラグメントシェーダ。

half4 frag(v2f i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
    // 以下省略
}

処理についてはマクロを追加するだけなのでとても簡単ですね。

マクロの役割

さて、ではこれらマクロはなにをしてくれているのでしょうか。
先にざっくり説明してしまうと、前述した配列へ適切にアクセスできるようにインデックスを処理する、ということになります。

ということでそれぞれのマクロを紐解いていきましょう。

TEXTURE2D_XとSAMPLER

これはテクスチャの宣言時に用いるマクロです。これがどう展開されるか見ていきましょう。

宣言では以下のマクロによって分岐が発生します。

#if defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)

見ての通り、マルチビュー(かGPUインスタンシング)がオンの場合に異なる挙動になります。

それぞれの定義を見ていくと最終的に以下のようにそれぞれ展開されることが分かります。

// 通常
#define TEXTURE2D(textureName)  Texture2D textureName

// マルチビュー
#define TEXTURE2D_ARRAY(textureName) Texture2DArray textureName

通常時はただのTexture2Dとして宣言され、マルチビューの場合はTexture2DArrayとして宣言されるのが分かりました。

続いてSAMPLERです。こちらは素直に以下に展開されます。

#define SAMPLER(samplerName) SamplerState samplerName

SAMPLE_TEXTURE2D_X

次は実際に利用する際のマクロです。これもどう展開されるか見てみましょう。
こちらも同様にマルチビューか否かによって分岐されます。

分岐後はそれぞれ以下のように展開されます。

// 通常
#define SAMPLE_TEXTURE2D(textureName, samplerName, coord2) textureName.Sample(samplerName, coord2)

// マルチビュー
// 以下を経由して、
#define SAMPLE_TEXTURE2D_X(textureName, samplerName, coord2) SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, SLICE_ARRAY_INDEX)

// 最終的にこう展開される
#define SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, index) textureName.Sample(samplerName, float3(coord2, index))

こちらはマルチビューの場合は少しだけ複雑です。とはいえ、配列へアクセスするための添字を追加してアクセスしている部分だけが異なりますね。
そしてその添字はSLICE_ARRAY_INDEXというマクロによってさらに展開されます。

SLICE_ARRAY_INDEXでテクスチャ配列の添字を得る

SLICE_ARRAY_INDEXは以下のように定義されています。

#define SLICE_ARRAY_INDEX   unity_StereoEyeIndex

XRっぽい記述が出てきました。次に説明するマクロによってこのインデックスが解決されます。
ここで大事な点は、マクロを利用することでテクスチャなのかテクスチャ配列なのかを気にせずに透過的に宣言が行えるという点です。

UNITY_VERTEX_OUTPUT_STEREO

構造体のところで使用したマクロです。名前からも分かるようにXR関連のレンダリングに関する設定になります。
UNITY_STEREO_MULTIVIEW_ENABLEDが定義されている場合に以下のように展開されます。

#define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO float stereoTargetEyeIndexAsBlendIdx0 : BLENDWEIGHT0;
#define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) unity_StereoEyeIndex = (uint) input.stereoTargetEyeIndexAsBlendIdx0;

unity_StereoEyeIndexSLICE_ARRAY_INDEXマクロが展開されたときに使われているものでした。ここでまさに定義され、値が設定されているというわけです。

UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)

頂点シェーダで利用されているマクロです。これは、適切にoutput.stereoTargetEyeIndexAsBlendIdx0の値を設定するために用いられます。

展開されたあとの状態を見てみましょう。

#define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) output.stereoTargetEyeIndexAsBlendIdx0 = unity_StereoEyeIndices[unity_StereoEyeIndex].x;

フラグメントシェーダに渡す構造体に値が設定されているのが分かるかと思います。利用されているのは前述のunity_StereoEyeIndexですね。
こうしてマクロを通して匠に値が設定されていくわけです。

なお、マルチビューではない場合はマクロは空になっているのでなにも展開されません。

UNITY_SETUP_INSTANCE_ID / UNITY_VERTEX_INPUT_INSTANCE_ID / UNITY_TRANSFER_INSTANCE_ID

最後にインスタンスIDについて見ていきましょう。
これはGPUインスタンシングで利用されるものです。
(なのでマルチビューでは使用されません)

// これは構造体にインスタンスIDを宣言するもの
#define DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID uint instanceID : SV_InstanceID;
// これは頂点シェーダからフラグメントシェーダへインスタンスIDを渡すための処理
#define UNITY_TRANSFER_INSTANCE_ID(input, output)   output.instanceID = UNITY_GET_INSTANCE_ID(input)
// 頂点シェーダ内でインスタンスIDをセットアップする
#define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) { UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input));}

最後のマクロだけ関数呼び出しが入ります。続けて関数UnitySetupInstanceIDも見てみましょう。

void UnitySetupInstanceID(uint inputInstanceID)
{
    #ifdef UNITY_STEREO_INSTANCING_ENABLED
        #if !defined(SHADEROPTIONS_XR_MAX_VIEWS) || SHADEROPTIONS_XR_MAX_VIEWS <= 2
            #if defined(SHADER_API_GLES3)
                // We must calculate the stereo eye index differently for GLES3
                // because otherwise,  the unity shader compiler will emit a bitfieldInsert function.
                // bitfieldInsert requires support for glsl version 400 or later.  Therefore the
                // generated glsl code will fail to compile on lower end devices.  By changing the
                // way we calculate the stereo eye index,  we can help the shader compiler to avoid
                // emitting the bitfieldInsert function and thereby increase the number of devices we
                // can run stereo instancing on.
                unity_StereoEyeIndex = round(fmod(inputInstanceID, 2.0));
                unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1);
            #else
                // stereo eye index is automatically figured out from the instance ID
                unity_StereoEyeIndex = inputInstanceID & 0x01;
                unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1);
            #endif
        #else
            unity_StereoEyeIndex = inputInstanceID % _XRViewCount;
            unity_InstanceID = unity_BaseInstanceID + (inputInstanceID / _XRViewCount);
        #endif
    #else
        unity_InstanceID = inputInstanceID + unity_BaseInstanceID;
    #endif
}

だいぶ長いですね。ただ#if defined(SHADER_API_GLES3)のほうはコメントにも書かれている通り、GLSL3以下のための回避策のようです。

ここで行っていることはそうしたデバイスの違いを吸収し、適切にunity_StereoEyeIndexunity_InstanceIDを設定することです。


長々とマクロを見てきましたが、行っていることを一言で言ってしまえば、マルチビュー(とGPUインスタンシング)の場合とそれ以外でエラーが出ないようにセットアップしてくれている、ということです。

そして大事な点はマルチビューなどの場合では「テクスチャ配列」を介して処理が行われるということです。
これを行わないと適切に描画されなくなってしまいます。

以上が、マルチビュー対応のためのシェーダの書き方でした。

ScriptableRenderPassでRenderTextureを生成する際の注意点

今回の修正の大半はシェーダでした。が、ひとつだけC#側でも対応しないとならない箇所があります。
それがRenderTextureDescriptorの取得箇所です。

とはいえコードはめちゃ短いので見てもらうほうが早いでしょう。

RenderTextureDescriptor descriptor = XRSettings.enabled ? XRSettings.eyeTextureDesc : camData.cameraTargetDescriptor;

XRSettings.enabledを見るとXRかどうかが判断できます。そしてその場合にはXRSettings.eyeTextureDescからdescriptorを取得することで適切なRenderTextureを得られるというわけです。

ちなみにdescriptorは「記述子」と訳されます。これは「どんなRenderTextureなのかを説明するもの」と考えるといいでしょう。
そしてそれを元にRenderTextureが取得されるため、VRのマルチビューの場合はTextureArrayの形でRenderTextureが取得されるというわけです。

参考にした記事

URPで背景をぼかしてuGUIの背景にする

概要

今までのビルトインパイプラインで利用していたCommandBufferはSRP環境では少し違った方法で実装しないとならないようです。
今回はSRPでのカスタムパスの使い方と、それを利用してuGUIの背景にブラーを掛けるエフェクトについて書きたいと思います。

前回書いたこの記事のURP(Universal Render Pipeline)版です。

edom18.hateblo.jp

なお、今回の内容を実行すると以下のような感じのエフェクトが作れます。

また実装したものはGitHubにも上げてあります。

github.com

Table of Contents

URPにおけるブラーエフェクトの作成手順

今回実装するのはブラーエフェクトですが、実装する内容はいわゆるカスタムパスの実装になっています。
ということで、そもそもSRP(Scriptable Render Pipeline)でカスタムのパスをどう実装するのかを概観してみましょう。

URP(つまりSRP)では以下の手順を踏んでエフェクトを作成する必要があります。

  1. ScriptableRenderFeatureクラスを継承したクラスを実装する
  2. ScripableRenderPassクラスを継承したクラスを実装する
  3. Custom Forward Rendererアセットを作成する*1
  4. (3)のアセットに(1)で作成したFeatureを追加する

大まかに見ると上記4点が必要な内容となります。

ビルトインパイプラインとの違い

ビルトインのレンダリングパイプラインではカメラの描画命令に差し込む形でCommandBufferを生成し、Cameraオブジェクトに追加することで処理を行っていました。

しかしURPでは(上のリストで示したように)独自のパスを実装し、それを差し込む形で実現します。
もともとURPはScriptable Render Pipelineを使って実装されたもので、パイプラインをスクリプタブルなものにしたものなので当たり前と言えば当たり前ですね。

ということで、以下からその手順の詳細を説明していきます。

Custom Forward Rendererアセットを作成する

まずはCustom Forward Rendererアセットを作成します。これは以下のようにCreateメニューから作成することができます。
(上のリストでは(3)にあたる部分ですが、手順としてはここから説明したほうがイメージしやすいと思うのでそこから説明します)

(前知識として)SRPではレンダーパイプラインを定義するアセットファイルがあり、それをプロジェクトに設定することで適用できるようになっています。

そして今回作成するこのアセットはそれと異なり、前述のパイプラインアセットにカスタムパス用リストとして追加するものになっています。
なのでこれを複数作成して追加することで簡単に複数のカスタムパスを追加することができるというわけです。

以下のキャプチャはパイプラインアセットにCustom Forward Rendererアセットを設定した図です。

ScriptableRendererFeatureクラスを実装する

次に説明するのは前述のリストの(1)の部分です。つまりScriptableRendererFeatureクラスを継承したクラスを作成します。
ScriptableRendererFeatureはまさにレンダリングの特徴です。
このクラスの役割はカスタムのパスをQueueに入れ、それをパイプラインで実行するよう指示することです。

そしてそれをForward Rendererアセットに登録します。(登録については後述)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;

public class BlurRendererFeature : ScriptableRendererFeature
{
    [SerializeField] private float _anyParam = 0;
    
    public override void Create()
    {
        Debug.Log("Create Blur Renderer Feature.");
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        Debug.Log("Add Render Passes.");
    }
}

上記コードは内容が分かりやすいようにダミーコードでの実装です。

ScriptableRendererFeatureクラスを継承したクラスを生成すると自動的にForward Rendererアセットで認識されます。
Custom Forward RendererアセットのAdd Renderer Featureボタンを押すとリストが表示されるのでそれを選択します。

なお、該当クラスで定義したSerializeFieldは以下のように自動で生成されたScriptableObjectのパラメータとして現れ、インスペクタで編集することができます。

設定が済むと上記クラスで実装したAddRenderPassesが毎フレーム呼ばれるようになります。
ここで追加のレンダーパスを実行して処理するという流れなわけですね。

上の例のまま登録すると以下のようにログが出続けるようになります。

カスタムパス版のUpdateメソッド、とイメージすると分かりやすいと思います。

ScriptableRenderPassクラスがパスを表す単位

RendererFeatureはパスを束ねる役割でした。一方、ScriptableRenderPassクラスはその名の通りパスを表す単位で、この中で実際に行いたい処理を書いていくことになります。

以下は簡単のため、たんにレンダリング結果の色味を反転するだけのパスです。
いわゆるポストエフェクトですね。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class ReverseColorRendererPass : ScriptableRenderPass
{
    private const string NAME = nameof(ReverseColorRendererPass);
    
    private Material _material = null;
    private RenderTargetIdentifier _currentTarget = default;
    
    public ReverseColorRendererPass(Material material)
    {
        renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
        _material = material;
    }

    public void SetRenderTarget(RenderTargetIdentifier target)
    {
        _currentTarget = target;
    }
    
    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer buf = CommandBufferPool.Get(NAME);
        CameraData camData = renderingData.cameraData;

        int texId = Shader.PropertyToID("_TempTexture");
        int w = camData.camera.scaledPixelWidth;
        int h = camData.camera.scaledPixelHeight;
        int shaderPass = 0;

        buf.GetTemporaryRT(texId, w, h, 0, FilterMode.Point, RenderTextureFormat.Default);
        buf.Blit(_currentTarget, texId);
        buf.Blit(texId, _currentTarget, _material, shaderPass);
        
        context.ExecuteCommandBuffer(buf);
        CommandBufferPool.Release(buf);
    }
}

コンストラクタで設定されているrenderPassEventRenderPassEvent型の変数で、ベースクラスであるScriptableRenderPassで定義されている変数です。
これはカスタムパスがどのタイミングでレンダリングされるべきかを示す値となり、任意の位置にパスを差し込むことができます。

この値はenumになっていて、+2など値を加減算することで細かくタイミングを制御できるようになっています。

ブラー処理を実装する

さて、ここからが本題です。
カスタムパスの挿入方法は掴めたでしょうか。

ブラー処理は大まかに以下のように処理をしていきます。

  1. 不透明オブジェクトがレンダリングされた結果をコピーする(処理負荷軽減のためダウンスケールする)
  2. ダウンスケールしたコピーに対してぼかし処理を適用する
  3. 他のシェーダで利用できるように、結果をテクスチャとして設定する
  4. (3)で設定されたテクスチャを背景にする

という流れです。

以下から詳細を見ていきましょう。

ブラー用のScriptableRendererFeatureを実装する

まずはScriptableRendererFeatureを継承したクラスを作成します。
インスペクタから値が設定できるようにいくつかのパラメータを定義し、このあと説明するパスの呼び出しまでを行います。

public class BlurRendererFeature : ScriptableRendererFeature
{
    [SerializeField] private Shader _shader = null;
    [SerializeField, Range(1f, 100f)] private float _offset = 1f;
    [SerializeField, Range(10f, 1000f)] private float _blur = 100f;
    [SerializeField] private RenderPassEvent _renderPassEvent = RenderPassEvent.AfterRenderingOpaques;

    private GrabBluredTextureRendererPass _grabBluredTexturePass = null;

    public override void Create()
    {
        Debug.Log("Create Blur Renderer Feature.");

        if (_grabBluredTexturePass == null)
        {
            _grabBluredTexturePass = new GrabBluredTextureRendererPass(_shader, _renderPassEvent);
            _grabBluredTexturePass.SetParams(_offset, _blur);
            _grabBluredTexturePass.UpdateWeights();
        }
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget);
        _grabBluredTexturePass.SetParams(_offset, _blur);
        renderer.EnqueuePass(_grabBluredTexturePass);
    }
}

CreateのタイミングでGrabBluredTextureRendererPassを生成し、適切な処理を行っています。
CreateMonoBehaviourで言うところのStartメソッドに当たる処理です。
初期化処理などはここで行うのが適切でしょう。

そしてすでに説明したように、AddRenderPassesメソッドが毎フレーム呼ばれパスの実行が促されます。
ここではGrabBluredTextureRendererPassをキューに入れているのが分かりますね。

GrabBluredTextureRendererPassクラスを実装する

ここがブラーを掛けるメイン処理となります。まずはコードを見てみましょう。

public class GrabBluredTextureRendererPass : ScriptableRenderPass
{
    private const string NAME = nameof(GrabBluredTextureRendererPass);

    private Material _material = null;
    private RenderTargetIdentifier _currentTarget = default;
    private float _offset = 0;
    private float _blur = 0;

    private float[] _weights = new float[10];

    private int _blurredTempID1 = 0;
    private int _blurredTempID2 = 0;
    private int _screenCopyID = 0;
    private int _weightsID = 0;
    private int _offsetsID = 0;
    private int _grabBlurTextureID = 0;

    public GrabBluredTextureRendererPass(Shader shader, RenderPassEvent passEvent)
    {
        renderPassEvent = passEvent;
        _material = new Material(shader);

        _blurredTempID1 = Shader.PropertyToID("_BlurTemp1");
        _blurredTempID2 = Shader.PropertyToID("_BlurTemp2");
        _screenCopyID = Shader.PropertyToID("_ScreenCopyTexture");
        _weightsID = Shader.PropertyToID("_Weights");
        _offsetsID = Shader.PropertyToID("_Offsets");
        _grabBlurTextureID = Shader.PropertyToID("_GrabBlurTexture");
    }

    public void UpdateWeights()
    {
        float total = 0;
        float d = _blur * _blur * 0.001f;

        for (int i = 0; i < _weights.Length; i++)
        {
            float r = 1.0f + 2.0f * i;
            float w = Mathf.Exp(-0.5f * (r * r) / d);
            _weights[i] = w;
            if (i > 0)
            {
                w *= 2.0f;
            }

            total += w;
        }

        for (int i = 0; i < _weights.Length; i++)
        {
            _weights[i] /= total;
        }
    }

    public void SetParams(float offset, float blur)
    {
        _offset = offset;
        _blur = blur;
    }

    public void SetRenderTarget(RenderTargetIdentifier target)
    {
        _currentTarget = target;
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer buf = CommandBufferPool.Get(NAME);

        ref CameraData camData = ref renderingData.cameraData;

        if (camData.isSceneViewCamera)
        {
            return;
        }

        RenderTextureDescriptor descriptor = camData.cameraTargetDescriptor;

        buf.GetTemporaryRT(_screenCopyID, descriptor, FilterMode.Bilinear);

        descriptor.width /= 2;
        descriptor.height /= 2;

        buf.GetTemporaryRT(_blurredTempID1, descriptor, FilterMode.Bilinear);
        buf.GetTemporaryRT(_blurredTempID2, descriptor, FilterMode.Bilinear);

        int width = camData.camera.scaledPixelWidth;
        int height = camData.camera.scaledPixelHeight;
        float x = _offset / width;
        float y = _offset / height;
        
        buf.SetGlobalFloatArray(_weightsID, _weights);

        buf.Blit(_currentTarget, _screenCopyID);
        Blit(buf, _screenCopyID, _blurredTempID1);
        buf.ReleaseTemporaryRT(_screenCopyID);

        for (int i = 0; i < 2; i++)
        {
            buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0));
            Blit(buf, _blurredTempID1, _blurredTempID2, _material);

            buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0));
            Blit(buf, _blurredTempID2, _blurredTempID1, _material);
        }
        
        buf.ReleaseTemporaryRT(_blurredTempID2);

        buf.SetGlobalTexture(_grabBlurTextureID, _blurredTempID1);

        context.ExecuteCommandBuffer(buf);
        CommandBufferPool.Release(buf);
    }
}

ScriptableRendererFeatureと同様にScriptableRenderPassにもoverrideしておくべきメソッドがあります。
一番重要なメソッドがExecuteです。その名の通り、パスの処理が実行されるべきタイミングで呼び出されるメソッドです。

今回はこのメソッド内で画面のキャプチャとブラー処理をしていきます。

いくつかの変数についてはCommandBufferやシェーダの扱いのためのものになるのでここでは説明を割愛します。
ブラー自体の詳細については前回の記事を参照ください。
ここではブラー処理を実行しているExecuteに絞って説明します。

Execute内でキャプチャとブラー処理を行う

Executeメソッドの実装自体はそれほど長くはありません。

なにをしているのかをコメントを付与する形でコード内で説明しましょう。

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    // CommandBufferをプールから取得する
    CommandBuffer buf = CommandBufferPool.Get(NAME);

    // カメラの設定などにまつわる情報を取得する
    ref CameraData camData = ref renderingData.cameraData;

    // 詳細は「ハマった点」で解説しますが、シーンビューだとおかしくなっていたので分岐を入れています。
    if (camData.isSceneViewCamera)
    {
        return;
    }

    // 今現在、このパスを実行しているカメラのれんだーターゲットに関する情報を取得します。
    RenderTextureDescriptor descriptor = camData.cameraTargetDescriptor;

    // シーン結果コピー用のテンポラリなRenderTextureを取得します。
    // 取得の際に、前段で取得したRenderTextureDescriptorを利用することで、カメラの描画情報と同じ設定のものを取得することができます。
    buf.GetTemporaryRT(_screenCopyID, descriptor, FilterMode.Bilinear);

    // 次に、ダウンスケールするために解像度を半分にします。
    descriptor.width /= 2;
    descriptor.height /= 2;

    // 解像度を半分にしたRenderTextureDescriptorを使ってブラー処理用のふたつのテンポラリなRTを取得します。
    buf.GetTemporaryRT(_blurredTempID1, descriptor, FilterMode.Bilinear);
    buf.GetTemporaryRT(_blurredTempID2, descriptor, FilterMode.Bilinear);

    // ここはブラー用のパラメータ調整です。
    int width = camData.camera.scaledPixelWidth;
    int height = camData.camera.scaledPixelHeight;
    float x = _offset / width;
    float y = _offset / height;
    
    buf.SetGlobalFloatArray(_weightsID, _weights);

    // 現在レンダリング中のレンダーターゲットをコピーします。
    // _currentTargetの詳細については大事な点なので後述します。
    buf.Blit(_currentTarget, _screenCopyID);

    // コピーした結果をダウンスケールしてブラー用RTにコピーします。
    // なお、ここではbuf.BlitではなくScriptableRenderPassのBlitを呼び出している点に注意してください。
    // 詳細は後述します。
    Blit(buf, _screenCopyID, _blurredTempID1);
    buf.ReleaseTemporaryRT(_screenCopyID);

    // ブラー処理
    for (int i = 0; i < 2; i++)
    {
        buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0));
        Blit(buf, _blurredTempID1, _blurredTempID2, _material);

        buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0));
        Blit(buf, _blurredTempID2, _blurredTempID1, _material);
    }
    
    buf.ReleaseTemporaryRT(_blurredTempID2);

    // ブラー処理したテクスチャをグローバルテクスチャとして設定します。
    buf.SetGlobalTexture(_grabBlurTextureID, _blurredTempID1);

    // 最後に、これら一連の流れを記述したCommandBufferを実行します。
    context.ExecuteCommandBuffer(buf);
    CommandBufferPool.Release(buf);
}

細かくコメントを付けてみたので詳細はそちらをご覧ください。
以下で2点、ハマりポイントも含めつつ解説します。

現在のレンダーターゲットをScriptableRenderFeatureからもらう

BlurRendererFeatureの実装で現在のレンダーターゲット(RenderTargetIdentifier型)を渡している箇所があります。
以下の部分ですね。

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget);

    // 後略
}

ScriptableRenderercameraColorTargetをパスに渡しています。
実は最初に実装した際、シーンのコピーをする際にBuiltinRenderTextureType.CameraTargetを利用していました。

が、後述する「ハマった点」でも取り上げるように、このレンダーターゲットはどうも1フレーム前の情報を格納しているようでした。
そのせいでブラーをかける対象がちらついたりして正常に動作していませんでした。

Frame Debuggerで見るとコピーすべきテクスチャ名が_CameraColorTextureになっていたのでもしや、と思ってcameraColorTargetを使うようにしたところ正常に動作するようになりました。

ScriptableRenderPassのBlitを使ってブラー処理を行う

これはちょっと理由が分からないのですが、CommandBufferBlitを利用してブラー処理を実行したところ、なぜかそれ以後のパスのレンダーターゲットがGrabBluredTextureRendererPass内で取得したテンポラリなRTになる現象がありました。

もう少し具体的に言うと、パス処理の中で最後に実行したbuf.Blitの第一引数に指定したレンダーターゲットが後半のパスのレンダーターゲットになってしまっている、という感じです。
しかしこれをScriptableRenderPassクラスのBlitを経由して実行することで回避することができました。

該当の処理を見てみると、確かにレンダーターゲットの変更っぽい処理がされているのでそれが原因かもしれません。
(ただ、最初のシーン結果のコピーにこちらのBlitを使うと正常に動作せず、しかもそのレンダーターゲットは以後のパスに影響しないという謎挙動なので確かなことは分かっていません・・・)

ブラー結果をuGUIの背景に設定する

さぁ、最後は処理した結果のテクスチャをuGUIの背景に指定するだけです。
実はこの処理は前回書いた記事とまったく同じになるので、詳細については以前の記事を参考にしてください。

edom18.hateblo.jp

まとめ

以上でURPでぼかし背景を作る方法の解説はおしまいです。
色々ハマりどころはありましたが、ブラー処理以外にも、そもそもSRPでカスタムのパスをどう差し込むのか、それらがどう動作するのかの概観を得ることができました。

今回のエフェクト以外にも、例えば特定のオブジェクトだけ影を別にレンダリングする、なんてこともできそうだなと思っています。
今回の実装はそうした意味でも色々と実りのあるものになりました。

最後に少しだけハマった点など備忘録としてメモを残しておくので興味がある方は見てみてください。

ハマった点

今回の実装にあたり、いくつかのハマりポイントがありました。

Unity Editor自体のUIがバグる

これは普通にUnityのバグな気がしないでもないんですが、自分が今回実装したパスを適用するとなぜかUnity Editorのシーンビューがおかしくなるというものです。

具体的にはタブ部分(他のビューをドッキングしたりできるあれ)が黒くなったり、あるいはシーンの一部が描画されてしまったり、という感じです。
(こんな感じ↓)

さすがにバグ感ありますよね・・。ってことでバグレポートもしてみました。

さて、とはいえこのままになってしまうと開発に多少なりとも支障が出てしまうので回避したいところです。
結論から言うと、シーンビューをレンダリングしている場合には処理をしない、ということで回避しました。(そもそもシーンビューに適用する意味のない処理だったので)

コードとしてはこんな感じです。

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    CommandBuffer buf = CommandBufferPool.Get(NAME);

    ref CameraData camData = ref renderingData.cameraData;
    
    if (camData.isSceneViewCamera)
    {
        return;
    }

    // ... 以下略
}

処置内容は、実際にパスが実行される際に呼ばれるExecuteメソッド内で、CameraData.isSceneViewCameratrueだったら処理せずすぐにreturnするだけです。

シーンビューのUIが表示されない

実は問題の原因自体は上のものと同一です。
ただ現象としてあったので書いておきます。

具体的には、シーンビュー内の、World Spaceに設定されたuGUIが描画されないという問題です。
原因は一緒っぽいので、上の回避方法を導入することでこちらも回避できました。

VRで描画がおかしくなる

結論から言うと、コピー元をBuiltinRenderTextureType.CameraTargetにしていたのが間違いでした。

buf.Blit(BuiltinRenderTextureType.CameraTarget, _screenCopyID);

最初原因がまったく分からず、色々試していくうちにふと思い立って、コピー結果をBefore Rendering Opaques時に無加工で表示するとなぜかすでに描画された状態になっていました。
これは推測ですが、ダブルバッファなどで「1フレーム前の状態」を保持しているのではないかなと思います。

VRだとレンダリングが遅れた際にタイムワープなどを使ってレンダリングの遅延を気にさせない機能があるので、それに利用しているんじゃないかなとか思ったり。(完全に推測です)

なので、ScriptableRendererFeature.AddRenderPasses時にパイプラインから渡されるScriptableRenderer.cameraColorTargetを利用してキャプチャを行うようにしたところ正常に表示されました。

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget);
    // 以下略
}

その他参考にした記事

github.com

github.com

*1:Forward Rendererなのは今回VR対応もしたためです。場合によっては別のRendererアセットを使うこともできます。

バイトニックソートの実装を理解する

概要

以前書いた粒子法を用いた流体シミュレーションをさらに発展させ近傍探索を行って最適化をしています。
その中で使っている『バイトニックソート』というソートについてまとめたいと思います。

本記事は近傍探索を実装する上でのサポート的な記事です。
近いうちに近傍探索の実装についても書こうと思っています。

なお、参考にさせていただいた記事は流体シミュレーション実装を参考にさせていただいた@kodai100さんが書いている記事です。

qiita.com

内容は近傍探索についてですがその中でバイトニックソートについての言及があります。

ちなみに流体シミュレーション自体についても記事を書いているので興味がある方はご覧ください。

edom18.hateblo.jp



バイトニックソートとは

Wikipediaによると以下のように説明されています。

バイトニックマージソート(英語: Bitonic mergesort)または単にバイトニックソート(英語: Bitonic sort)とは、ソートの並列アルゴリズムの1つ。ソーティングネットワークの構築法としても知られている。

このアルゴリズムはケン・バッチャー(英語: Ken Batcher)によって考案されたもので、このソーティングネットワークの計算量はn個の要素に対して、サイズ(コンパレータ数=比較演算の回数)は O(n log^{2}(n))、段数(並列実行不可能な数)は O(log^{2}(n))となる[1]。各段での比較演算(n/2回)は独立して実行できるため、並列化による高速化が容易である。

自分もまだしっかりと理解できてはいませんが、ソーティングネットワークを構築することで配列の中身を見なくともソートができる方法のようです。

この配列の中を見なくてもというのがポイントで、決められた順に処理を実行していくだけでソートが完了します。
言い換えると並列に処理が可能ということです。

Wikipediaでも

並列化による高速化が容易である。

と言及があります。

GPU(コンピュートシェーダ)によって並列に計算する必要があるためこの特性はとても重要です。

ロジックを概観する

まずはWikipediaに掲載されている以下の図を見てください。

f:id:edo_m18:20200916090952p:plain

最初はなんのこっちゃと思いましたが、ひとつずつ見ていけばむずかしいことはしていません。

まずぱっと目に着くのは線と色のついた各種ボックスだと思います。
この図が言っているのは配列の中身が16要素あり、それをソートしていく様を示しています。

一番左が初期状態で、一番右がソートが完了した状態です。
よく見ると横に長く伸びる線が16本あることに気づくと思います。
これが配列の要素数を表しています。


余談ですが、なぜこういう図なのかと言うと。
ソーティングネットワーク自体がこういう概念っぽいです。

横に伸びる線をワイヤー、矢印の部分がコンパレータと呼ばれます。
そしてワイヤーの左からデータを流すと、まるであみだくじの要領でソートが完了します。
そのためにこういう図になっているというわけなんですね。


ソートされていく様子はあみだくじを想像するといいかもしれません。
左からデータが流れてきて、決まったパターンでデータが入れ替わっていき、最後にソートが完了している、そんなイメージです。

このデータが入れ替わっていく部分は矢印が表しています。
また矢印の向きは降順・昇順どちらに値を入れ替えるかを表しています。

並列可能部分と不可能部分

まず注目すべきはブロックによって区切られている点です。
以下の図をご覧ください。

f:id:edo_m18:20200918090923p:plain

メインブロックと書かれたところが大きく4つに分かれています。
そしてそのメインブロックの中にサブブロック郡があります。

ここで注意する点は並列計算可能な部分並列計算不可能な部分がある点です。
図を見てもらうと分かりますが、メインブロック内の矢印に着目するとそれぞれは独立して処理を行えることが分かります。
どの矢印から処理を開始しても結果は変わりません。

しかしメインブロック自体の計算順序を逆にしてしまうと結果が異なってしまいます。
これは並列実行できないことを意味しています。
今回の目的はGPUによって並列計算を行わせることなのでここの把握は重要です。

つまりメインブロックは並列不可、サブブロックは並列可ということです。

計算回数

次にブロックの処理順について法則を見てみましょう。

メインブロックは全部で4つあります。そして配列数は16です。
この関連性は 2^4=16)から来ています。

これは推測ですが、バイトニックの名前の由来はこの2進数から来ているのかもしれません。

さて、ではサブブロックはどうでしょうか。
サブブロックにも法則があります。
それは左から順に1, 2, 3, 4, ...と数が増えていることです。

この法則はコードを見てみると分かりやすいです。

for (int i = 0; i < 4; i++)
{
    for (int j = 0; j <= i; j++)
    {
        // ソート処理
    }
}

外側のforループがメインブロックのループを表していて、内側のループがサブブロックのループを表しています。
そして内側のループは外側のループが回るたびに回数が増えていく形になっています。

外側のループが1回なら内側も1回だけ実行され、外側の2ループ目は内側は2回ループする、という具合です。
なので外側のループが回るたびに内側のループの回数が増加していくというわけなんですね。

計算最大回数

今回は要素数16なので4でしたが、これが32なら5回ループが回るということですね。
もちろん、内側のループもそれに応じて増えていきます。
メインブロックの最大計算回数は素数2の何乗かに依るわけです。

ちなみに感の良い方ならお気づきかもしれませんが、2のべき乗で計算がされるということはそれ以外の要素数ではソートが行えないことを意味しています。
なのでもし要素数が2のべき乗以外の数になる場合はダミーデータなどを含めて2のべき乗に揃える必要があります。

矢印の意味

さらに詳細を見ていきましょう。

次に見るのは矢印です。
この矢印は配列内の要素を入れ替える(Swapする)ことを意味しています。
矢印なので向きがありますね。これは昇順・降順どちらに入れ替えるかを示しています。

よく見ると青いブロック内は昇順、緑のブロック内は降順に入れ替わっていることが分かります。
そして図の通りに入れ替えを進めていくと最終的にソートが完了している、というのがバイトニックソートです。

ひとつの解説だけだと解像度が足らないので別の記事でも探してみると、以下の記事と画像が理解を深めてくれました。

seesaawiki.jp

画像を引用させてもらうと以下のような感じでソートが進んでいきます。

f:id:edo_m18:20200915093123p:plain f:id:edo_m18:20200915093532p:plain

言っていることはWikipediaと同じですが実際の数値が並び変えられていくのでより理解が深まるかと思います。

ちなみにこの入れ替え手順先に示したコード通りになっているのが分かります。
各配列の下に添えられている数字を見ると2のべき乗の部分が0, 1, 0, 2, 1, 0と変化しているのが分かると思います。
これをグループ化して見てみると[0], [1, 0], [2, 1, 0]ということですね。
外側のループ回数が増えるにつれて内側のループが増えていくということと一致しています。

比較する対象の距離と方向を求める

さて、ループについては把握できたかと思います。
次に見るのはどの要素同士を入れ替えるかという点です。

入れ替える距離はループ数によって決まる

ループの仕方が分かっても、闇雲に配列の内容を入れ替えたのでは当然ソートはできません。
ではどういうルールで入れ替えていけばいいのでしょうか。

その答えは以下の計算です。

public static void Kernel(int[] a, int p, int q)
{
    int d = 1 << (p - q);
    
    for (int i = 0; i < a.Length; i++)
    {
        bool up = ((i >> p) & 2) == 0;
        
        if ((i & d) == 0 && (a[i] > a[i | d]) == up)
        {
            int t = a[i];
            a[i] = a[i | d];
            a[i | d] = t;
        }
    }
}

public static void BitonicSort(int logn, int[] a)
{
    for (int i = 0; i < logn; i++)
    {
        for (int j = 0; j <= i; j++)
        {
            Kernel(a, i, j);
        }
    }
}

距離に関しては以下の式で求めています。

// distanceのd
int d = 1 << (p - q);

ここでpは外側のループ、qは内側のループの回数が渡ってきます。
これを引き算の部分だけ見てみると、ループが進むに連れて以下のように計算されます。

0 - 0 = 0
1 - 0 = 1
1 - 1 = 0
2 - 0 = 2
2 - 1 = 1
2 - 2 = 0
...

引き算の結果は1bitをどれだけ左にシフトするかの数値なので、つまりは2を何乗するかを示しているわけですね。
これを把握した上で改めて先ほどの図を見てみると、確かにそう変化していっているのが分かると思います。

f:id:edo_m18:20200915093123p:plain f:id:edo_m18:20200915093532p:plain

昇順・降順は2bit目が立っているかで切り替える

昇順・降順を決めている計算は以下の部分です。

for (int i = 0; i < a.Length; i++)
{
    bool up = ((i >> p) & 2) == 0;

    // 後略
}

iは要素数分ループする回数を示しています。そしてpは前述の通り、外側のループ、つまりメインブロックの計算回数を示しています。

つまり、全要素をループさせ、かつそのループ回数をメインブロックの計算回数値だけ右にシフトし、そのときのビット配列の2bit目が立っているか否かで昇順・降順を切り替えているわけですね。(ちなみに2bit目が立っている場合は降順

ちょっとしたサンプルを書いてみました。
以下のpaizaのコードを実行すると、上の図の昇順・降順の様子と一致していることが分かるかと思います。
(サンプルコードの↓が昇順、↑が降順を表しています)

Swap処理

最後に、どの場合にどこと入れ替えるかの処理について見てみましょう。

bool up = ((i >> p) & 2) == 0;

if ((i & d) == 0 && (a[i] > a[i | d]) == up)
{
    int t = a[i];
    a[i] = a[i | d];
    a[i | d] = t;
}

最初の行のupのは昇順か降順かのフラグです。
続くif文が実際にSwapをするかを判定している箇所になります。

条件が2つ書かれているのでちょっと分かりづらいですが、分解してみると以下の2つを比較しています。

// ひとつめ
(i & d) == 0
// ふたつめ
(a[i] > a[i | d]) == up

ひとつめは要素の位置とdとの理論積になっていますね。

ふたつめは、配列の要素のふたつの値を比較し、upフラグの状態と比較しています。
これは昇順か降順かを判定しているに過ぎません。

問題は右側の要素へのアクセス方法ですね。
a[i | d]はなにをしているのでしょうか。

これらが意味するところは以下の記事がとても詳しく解説してくれています。

qiita.com

この記事から引用させてもらうと、

あるインデックスに対してそれと比較するインデックスはdだけ離れています。そのため2つのインデックスをビットで考えると値はp - qビット目の値が0か1かの違いだけになります(一番右端のビットを0ビット目として数えています)。配列の先頭に近いほうにあるインデックスをiとすると比較対象のインデックスはp - qビット目が1になるのでそのインデックスはi | dになります。つまり、if文内の(i & d) == 0は配列の先頭に近いほうにあるインデックスかどうかを確認しており、x[i] > x[i | d]で2つの値の大小を確認していることになります。

と書かれています。

文章だけだとちょっと分かりづらいですが、実際にbitを並べて図解してみると分かりやすいと思います。
試しに要素数8の場合で書き下してみると、

000 = 0
001 = 1
010 = 2
011 = 3
100 = 4
101 = 5
110 = 6
111 = 7

というふうになります。値の意味は配列の添字です。(要素数8なので0 ~ 7ということです)

以下の部分を考えてみましょう。

あるインデックスに対してそれと比較するインデックスはdだけ離れています。

仮にd = 1 << 0だとするとdの値は1です。つまりひとつ隣ということですね。

000 = 0 ┐
001 = 1 ┘
010 = 2 ┐
011 = 3 ┘

比較する対象はこうなります。そして引用元では、

そのため2つのインデックスをビットで考えると値はp - qビット目の値が0か1かの違いだけになります(一番右端のビットを0ビット目として数えています)。

と書かれています。上の例ではp - q == 0としているので、つまりは一番右側(0番目)のビットの違いを見れば良いわけです。
見てみると確かに違いは0ビット目の値の違いだけであることが分かります。

冗長になるのでこれ以上深堀りはしませんが、実際に書き下してみると確かにその通りになるのが分かります。
そしてここを理解するポイントは以下です。

  • 比較対象のうち、配列の先頭に近い方のインデックスの場合のみ処理する
  • 先頭に近いインデックスだった場合は、そのインデックスとそのインデックスからdだけ離れた要素と比較する

ということです。
まぁ細かいことは置いておいても、for文で全部を処理している以上、重複してしまうことは避けられないので、それをビットの妙で解決しているというわけですね。

これを一言で言えば、先頭のインデックスだった場合は、そのインデックスとdだけ離れたインデックス同士を比較するということです。

コード全体

最後にコード全体を残しておきます。
以下はC#で実装した例です。コード自体はWikipediaJavaの実装をそのまま移植したものです。

// This implementation is refered the WikiPedia
//
// https://en.wikipedia.org/wiki/Bitonic_sorter
public static class Util
{
    public static void Kernel(int[] a, int p, int q)
    {
        int d = 1 << (p - q);
        
        for (int i = 0; i < a.Length; i++)
        {
            bool up = ((i >> p) & 2) == 0;
            
            if ((i & d) == 0 && (a[i] > a[i | d]) == up)
            {
                int t = a[i];
                a[i] = a[i | d];
                a[i | d] = t;
            }
        }
    }
    
    public static void BitonicSort(int logn, int[] a)
    {
        for (int i = 0; i < logn; i++)
        {
            for (int j = 0; j <= i; j++)
            {
                Kernel(a, i, j);
            }
        }
    }
}

public class Example
{
    public static void Main()
    {
        int logn = 5, n = 1 << logn;
        
        int[] a0 = new int[n];
        System.Random rand = new System.Random();
        for (int i = 0; i < n; i++)
        {
            a0[i] = rand.Next(n);
        }
        
        for (int k = 0; k < a0.Length; k++)
        {
            System.Console.Write(a0[k] + " ");
        }
        
        Util.BitonicSort(logn, a0);
        
        System.Console.WriteLine();
        
        for (int k = 0; k < a0.Length; k++)
        {
            System.Console.Write(a0[k] + " ");
        }
    }
}

実際に実行する様子は以下で見れます。

まとめ

まとめると、バイトニックソートは以下のように考えることができます。

  • 比較する配列の要素は常にふたつ
  • 比較対象はビット演算によって求める
  • 昇順・降順の判定もビットの立っている位置によって決める
  • 配列の要素の比較重複(0 -> 1と0 <- 1という向きの違い)もビットの位置によって防ぐ

分かってしまえばとてもシンプルです。
が、理解もできるし使うこともできるけれど、これを思いつくのはどれだけアルゴリズムに精通していたらできるんでしょうか。
こうした先人の知恵には本当に助けられますね。

XRCameraSubSystemから直接カメラの映像を取得する

概要

今回はUnityのARFoundationが扱うシステムからカメラ映像を抜き出す処理についてまとめたいと思います。
これを利用する目的は、カメラの映像をDeep Learningなどに応用してなにかしらの出力を得たいためです。

今回の画像データの取得に関してはドキュメントに書かれているものを参考にしました。

docs.unity3d.com

今回のサンプルを録画したのが以下の動画です。

今回のサンプルはGitHubにアップしてあるので、詳細が気になる方はそちらをご覧ください。

github.com

全体の流れ

画像を取得する全体のフローを以下に示します。

  1. XRCameraImageを取得する
  2. XRCameraImage#Convertを利用してデータを取り出す
  3. 取り出したデータをTexture2Dに読み込ませる
  4. Texture2Dの画像を適切に回転しRenderTextureに書き出す

という流れになります。

ということでひとつずつ見ていきましょう。

XRCameraImageを取得し変換する

ここではXRCameraImageからデータを取得し、Texture2Dに書き込むまでを解説します。

まずはARのシステムからカメラの生データを取り出します。
ある意味でこの工程が今回の記事のほぼすべてです。

取り出したらDeep Learningなどで扱えるフォーマットに変換します。

今回実装したサンプルのコードは以下の記事を参考にさせていただきました。

qiita.com

ドキュメントの方法でも同様の結果を得ることができますが、テクスチャの生成を制限するなど最適化が入っているのでこちらを採用しました。

以下に取り出し・変換する際のコード断片を示します。

private void RefreshCameraFeedTexture()
{
    // TryGetLatestImageで最新のイメージを取得します。
    // ただし、失敗の可能性があるため、falseが返された場合は無視します。
    if (!_cameraManager.TryGetLatestImage(out XRCameraImage cameraImage)) return;

    // 中略

    // デバイスの回転に応じてカメラの情報を変換するための情報を定義します。
    CameraImageTransformation imageTransformation = (Input.deviceOrientation == DeviceOrientation.LandscapeRight)
        ? CameraImageTransformation.MirrorY
        : CameraImageTransformation.MirrorX;

    // カメライメージを取得するためのパラメータを設定します。
    XRCameraImageConversionParams conversionParams =
        new XRCameraImageConversionParams(cameraImage, TextureFormat.RGBA32, imageTransformation);

    // 生成済みのTexture2D(_texture)のネイティブのデータ配列の参照を得ます。
    NativeArray<byte> rawTextureData = _texture.GetRawTextureData<byte>();

    try
    {
        unsafe
        {
            // 前段で得たNativeArrayのポインタを渡し、直接データを流し込みます。
            cameraImage.Convert(conversionParams, new IntPtr(rawTextureData.GetUnsafePtr()), rawTextureData.Length);
        }
    }
    finally
    {
        cameraImage.Dispose();
    }

    // 取得したデータを適用します。
    _texture.Apply();

    // 後略
}

Texture2Dの画像を適切に回転しRenderTextureに書き出す

前段でXRCameraImageからデータを取り出しTexture2Dへ書き出すことができました。
ただ今回は最終的にTensorFlow Liteで扱うことを想定しているのでRenderTextureに情報を格納するのがゴールです。

ぱっと思いつくのはGraphics.Blitを利用してRenderTextureにコピーすることでしょう。
しかし、取り出した画像は生のデータ配列のため回転を考慮していません。(つまりカメラからの映像そのままということです)

以下の質問にUnityの中の人からの返信があります。

forum.unity.com

TryGetLatestImage exposes the raw, unrotated camera data. It will always be in the same orientation (landscape right, I believe). The purpose of this API is to allow for additional CPU-based image processing, such as with OpenCV or other computer vision library. These libraries usually have a means to rotate images, or accept images in various orientations, so we there is no built-in functionality to rotate the image.

要は、だいたいの場合において利用する対象(OpenCVなど)に回転の仕組みやあるいは回転を考慮しないでそのまま扱える機構があるからいらないよね、ってことだと思います。

そのため、人が見て適切に見えるようにするためには画像を回転してコピーする必要があります。
ですが心配いりません。処理自体はとてもシンプルです。

基本的には時計回りに90度回転させるだけでOKです。

なにも処理しない画像をQuadに貼り付けると以下のような感じで90度回転したものが出力されます。
(ちょっと分かりづらいですが、赤枠で囲ったところはAR空間に置かれたQuadで、そこにカメラの映像を貼り付けています)

これを90度回転させるためにはUVの値を少し変更するだけで達成することができます。

まずはシェーダコードを見てみましょう。

シェーダで画像を回転させる

見てもらうと分かりますが、基本はシンプルなImage EffectシェーダでUVの値をちょっと工夫しているだけです。

Shader "Hidden/RotateCameraImage"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                float x = 1.0 - i.uv.y;
                float y = i.uv.x;
                float2 uv = float2(x, y);
                fixed4 col = tex2D(_MainTex, uv);
                return col;
            }
            ENDCG
        }
    }
}

x, yを反転して、さらにyの値を1.0から引いているだけです。簡単ですね。

そしてこのシェーダを適用したマテリアルを用いてGraphics.Blitを実行してやればOKです。

Graphics.Blit(texture, _previewTexture, _transposeMaterial);

分かりやすいように、グリッドの画像で適用したものを載せます。

90度右に回転しているのが分かるかと思います。

これで無事、画像が回転しました。

バイスの回転を考慮する

実は上のコードだけでは少し問題があります。
バイスの回転によって取得される画像データの見栄えが変わってしまうのです。

というのは、Portraitモードでは回転しているように見える画像でも、Landscapeモードだとカメラからの映像と見た目が一致して問題なくなるのです。
以下の動画を見てもらうと分かりますが、Portraitモードでは90度回転しているように見える画像が、Landscapeモードでは適切に見えます。

結論としてはPortraitモードのときだけ処理すればいいことになります。

private void PreviewTexture(Texture2D texture)
{
    if (_needsRotate)
    {
        Graphics.Blit(texture, _previewTexture, _transposeMaterial);
    }
    else
    {
        Graphics.Blit(texture, _previewTexture);
    }

    _renderer.material.mainTexture = _previewTexture;
}

バイスが回転した際のイベントが実はUnityには用意されていないようで、以下の記事を参考に回転の検知を実装しました。
(まぁゲームにおいて回転を検知してなにかをする、っていうケースが稀だからでしょうかね・・・)

forum.unity.com

カメライメージを取得するタイミング

最後にカメライメージの取得タイミングについて書いておきます。
ドキュメントにも書かれていますが、ARCameraManagerのframeReceivedというイベントのタイミングでカメライメージを取得するのが適切なようです。

ARCameraManager#frameReceivedイベント

ARCameraManagerにはframeReceivedというイベントがドキュメントでは以下のように説明されています。

An event which fires each time a new camera frame is received.

カメラフレームを受信したタイミングで発火するようですね。
なのでこのタイミングで最新のカメラデータを取得することで対象の映像を取得することができるというわけです。

ということで、以下のようにコールバックを設定してその中で今回の画像取得の処理を行います。

[SerializeField] private ARCameraManager _cameraManager = null;

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

_cameraManager.frameReceived += OnCameraFrameReceived;

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

private void OnCameraFrameReceived(ARCameraFrameEventArgs eventArgs)
{
    RefreshCameraFeedTexture();
}

最後に

無事、カメライメージを取得して扱える状態に変換することができました。
Texture2DとしてもRenderTextureとしても扱えるので用途に応じて使うといいでしょう。

気になる点としてはパフォーマンスでしょうか。
一度CPUを経由しているのでそのあたりが気になるところです。(まだ計測はしていませんが・・・)

が、シンプルな今回のデモシーンでは特に重さは感じなかったので、コンテンツが重すぎない限りは問題ないかなとも思います。

TensorFlow Lite Unity Pluginを利用してDeepLabを動かすまで

概要

最近、ARでSemantic Segmentaionを試そうと色々やっているのでそのメモです。
最終的には拾ってきたDeepLabモデルを変換して実際に動かすまでをやってみようと思います。

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

asus4.hatenablog.com

元々TensorFlow LiteにはUnity Pluginがあるようで、上記ブログではそれを利用してUnityのサンプルを作ってるみたいです。
サンプル自体もTensorFlowにあるものを移植したもののようです。

公開されているUnityのサンプルプロジェクトは以下です。
今回の記事もこれをベースに色々調べてみたものをまとめたものです。

github.com

今回試したものは、以下のような感じで動作します。

youtu.be



全体を概観する

まずはTensorFlowについて詳しくない人もいると思うのでそのあたりから書きたいと思います。

TensorFlow Liteとは

いきなりサンプルの話にはいる前に、ざっくりとTensorFlow Liteについて触れておきます。

TensorFlow Liteとは、TensorFlowをモバイルやIoTデバイスなど比較的非力なマシンで動作させることを目的に作られたものです。
つまり裏を返せば、通常のTensorFlowはモバイルで動作させるには重くて向いていないということでもあります。

詳細についてはTensorFlow Liteのサイトをご覧ください。

www.tensorflow.org

こちらの記事も参考になります。

note.com

TensorFlow Liteのモデル

TensorFlow Liteのモデルは.tfliteという拡張子で提供され、主に、TensorFlow本体で作られた(訓練された)データを変換することで得ることができます。サイトから引用すると以下のように説明されています。

To use a model with TensorFlow Lite, you must convert a full TensorFlow model into the TensorFlow Lite format—you cannot create or train a model using TensorFlow Lite. So you must start with a regular TensorFlow model, and then convert the model.

特にこの「you must convert a full TensorFlow model into the TensorFlow Lite format.」というところからも分かるように、TensorFlow LiteではTensorFlowのすべての機能を使えるわけではないということです。
逆に言えば、軽量化・最適化を施して機能を削ることでエッジデバイススマホやIoTデバイス)でも高速に動作するというわけなんですね。

DeepLabとは

今回試したのは『DeepLab』と呼ばれる、Googleが提案した高速に動作するセマンティックセグメンテーションのモデル(ネットワーク)です。
セマンティックセグメンテーションについては後日書く予定ですが、こちらのPDFがだいぶ詳しく解説してくれているのでそちらを見るとより理解が深まるかと思います。

DeepLabについてはGoogleのブログでも記載があります。

ai.googleblog.com

ものすごくざっくり言うと、セマンティックセグメンテーションとは、画像の中にある物体を認識しその物体ごとに色分けして分類する、という手法です。
手法自体はいくつか提案されており、今回使用したDeepLabもそうした手法のうちのひとつです。

特徴としてはモバイルでも動作するほど軽く、高速に動くという点です。
そのためARで利用することを考えた場合に、採用する最有力候補になります。(なので今回試した)

ちなみにGitHubにも情報が上がっています。

github.com

ひとまず、前提知識として知っておくことは以上となります。
次からは実際にUnityで動かす過程を通して、最終的には拾ってきたDeepLabモデルを変換して実際に動かすまでをやってみようと思います。

Unityサンプルを動かす

Unityのサンプルを動かしてみましょう。
まずはGitHubからダウンロードしてきたものをそのまま実行してみます。

youtu.be

動画を見てもらうと自転車と車に対して反応しているのが分かるかと思います。

サンプルプロジェクトに含まれるモデルはこちらで配布されているものです。

スターターモデルと書いてあるように、若干精度は低めかなという印象です。
本来的にはここから自分の目的にあったものにさらに訓練していくのだと思います。

サンプル以外のモデルを試す

サンプルに含まれているモデルが動くことが確認できました。
次は別のモデルを試してみることにします。

モデルを変換する

さて、別のモデルといってもイチから訓練するものではなく、すでに訓練され公開されているものを探してきてそれを変換したものを使ってみたいと思います。
これによってサンプルとの違いができ、より深く動作を確認できると思います。

ということで参考にさせてもらったのが以下のリポジトリです。

github.com

ここのREADMEの下の方にあるリンクからモデルをダウンロードしてきます。

TensorFlow Liteのモデルへの変換について

TensorFlowで訓練したモデルをLite版へと変換していきます。

ちなみにTensorFlowのモデルにはいくつかの形式があり、以下の形式が用いられます。

  • SavedModel
  • Frozen Model
  • Session Bundle
  • Tensorflow Hub モジュール

これらのモデルについては以下のブログを参照ください。

note.com

今回はこの中の『Frozen Model』で保存されたものを扱います。

ちなみにもし自分で訓練データを作成する場合は注意しなければならない点があります。

前節で書いたように、TensorFlow Liteはいくつかの機能を削減して軽量化を図るものです。
そのため、TensorFlowでは問題なく動いていたものが動かないことも少なくありません。

このあたりについてはまだ全然詳しくないのでここでは解説しません。(というかできません)

以下の記事が変換についてとても詳しく書かれているので興味がある方は参照してみてください。
ちなみに以下の記事は「量子化(Quantization)」を目的とした変換に焦点を絞っています。

qiita.com


以下、余談。

量子化(Quantization)とは

上記記事では以下のように説明されています。

上記のリポジトリから Freeze_Graph (.pb) ファイルをダウンロードします。 ココでの注意点は ASPP などの特殊処理が入ったモデルは軒並み量子化に失敗しますので、なるべくシンプルな構造のモデルに限定して取り寄せることぐらいです。

※ ... ちなみにASPP - Atrous Spatial Pyramid Poolingの略です。

ASPPがなにかを調べてみたら以下の記事を見つけました。

37ma5ras.blogspot.com

記事には以下のように記載されています。

  1. 画面いっぱいに写っていようと画面の片隅に写っていようと猫は猫であるように,image segmentationではscale invarianceを考慮しなければならない. 著者はAtrous Convolutionのdilationを様々に設定することでこれに対処している(fig.2). 著者はこの技法を”atrous spatial pyramid pooling”(ASPP)と呼んでいる.

さらに該当記事から画像を引用させてもらうと、

おそらくこの画像の下の様子がピラミッドに見えることからこう呼んでいるのだと思います。
が、量子化に際してこの技法が含まれていると変換できないようなのでここではあまり深堀りしません。

簡単に量子化について触れておくと、以下の記事にはこう説明されています。

情報理論における量子化とは、アナログな量を離散的な値で近似的に表現することを指しますが、本稿における量子化は厳密に言うとちょっと意味が違い、十分な(=32bitもしくは16bit)精度で表現されていた量を、ずっと少ないビット数で表現することを言います。

ニューラルネットワークでは、入力値とパラメータから出力を計算するわけですが、それらは通常、32bitもしくは16bit精度の浮動小数点(の配列)で表現されます。この値を4bitや5bit、もっと極端な例では1bitで表現するのが量子化です。1bitで表現する場合は二値化(binarization)という表現がよく使われますが、これも一種の量子化です。

量子化には、計算の高速化や省メモリ化などのメリットがあります。

developer.smartnews.com

要するに、本来はintfloatなど「大きな容量(16bit ~ 32bit)」を使うものをより小さい容量でも同じような精度を達成する方法、ということでしょうか。
これにはメモリ的なメリットや、単純に演算数の削減が見込めそうです。

モバイルではとにかくこのあたりの最適化は必須になるので量子化は必須と言ってもいいと思います。
(そういう意味で、量子化モデルの変換記事はめちゃめちゃ濃いので一度目を通しておくといいと思います)

が、後半で説明するtfliteへの変換でQuantizationのパラメータを設定するとうまく動作しなかったのでさらなる調査が必要そうです・・・。


閑話休題

モデルの入力・出力を調べる

ではモデルを変換していきましょう。
モデルを変換するためにはいくつかの情報を得なければなりません。

ここでは詳細は割愛しますが、ディープラーニングではニューラルネットワークへの入力出力(Input / Output)が大事になってきます。

ものすごくざっくり言えば、ディープラーニングはひとつの関数です。
プログラムでも、関数を利用したい場合はその引数と戻り値がなにかを知る必要があることに似ています

自分でネットワークを構築し訓練したものであればすでに知っている情報かもしれませんが、だいたいの場合はどこからか落としてきたモデルや、あるいは誰かの構築したネットワークを利用するというケースがほとんどでしょう。

そのため入力と出力を調べる必要があります。
調べ方については上のほうで紹介したこちらの記事が有用です。

そこで言及されていることを引用させていただくと、

INPUTは Input、 形状と型は Float32 [?, 256, 256, 3]、 OUTPUTは ArgMax、 形状と型は Float32 [?, 256, 256] のようです。 なお一見すると ExpandDims が最終OUTPUTとして適切ではないか、と思われるかもしれませんが、 実は Semantic Segmentation のモデルにほぼ共通することですが ArgMax を選定すれば問題ありません。

とあります。
ここで言及されている名前はネットワーク次第なので毎回これになるとは限りません。
しかし

実は Semantic Segmentation のモデルにほぼ共通することですが ArgMax を選定すれば問題ありません。

というのはとても大事な点なので覚えておきましょう。

ちなみにモデルの中身を可視化するツールがあります。
以下の『Netron』というものです。

Webサービスを利用して見てもいいですし、アプリもあるのでよく使う場合はインストールしておいてもいいでしょう。

lutzroeder.github.io

では先ほど落としてきたモデルを読み込ませて見てみましょう。

入力はInputという名前で、入力の型はfloat32、入力のサイズは256 x 2563チャンネルというのが分かります。
とても簡単に可視化できるのでとてもオススメのツールです。

続けて出力も見てみましょう。

参考にした記事に習ってArgMaxの部分を見てみます。
確認すると出力はArgMaxという名前でint32型、256 x 256の出力になるようです。

モデルの変換には「tflite_convert 」コマンドを使う

情報が揃ったのでダウンロードしてきたモデルを変換します。

変換にはtflite_convertコマンドを利用します。

今回の変換では以下のように引数を指定しました。

tflite_convert ^
  --output_file=converted_frozen_graph.tflite ^
  --graph_def_file=frozen_inference_graph.pb ^
  --input_arrays=Input^
  --output_arrays=ArgMax ^
  --input_shapes=1,256,256,3 ^
  --inference_type=FLOAT ^
  --mean_values=128 ^
  --std_dev_values=128

引数に指定している--input_arrays--output_arraysが、先ほど調べた名前になっているのが分かります。
さらに--input_shapesには入力の形として先ほど調べた256 x 256を指定しています。
これを指定することで適切に変換することができます。

では変換されたモデルをNetronで可視化してみましょう。

内容が変化しているのが分かります。
これをUnityのサンプルプロジェクトに入れて利用してみます。

これであとは実行するだけ・・・にはいきません。

drive.google.com ※ 変換したモデルを念の為公開しておきます。

モデルに合わせてC#を編集する

実はサンプルで用意されているDeepLabスクリプトはサンプルに含まれているモデルに合わせて実装されているため、今回のケースの場合は少し修正をしなければなりません。

なのでNetronによって確認できる型や形状に定義を変更します。
ということでDeepLabクラスを修正します。

今回修正した内容のdiffは以下です。

さて、さっそく変換したモデルを読み込ませて使ってみましょう。
・・・と勢い込んでビルドしてみるものの動かず。Logcatで見てみると以下のようなエラーが表示されていました。

08-07 10:50:49.160: E/Unity(6127): Unable to find libc
08-07 10:50:49.163: E/Unity(6127): Following operations are not supported by GPU delegate:
08-07 10:50:49.163: E/Unity(6127): ARG_MAX: Operation is not supported.
08-07 10:50:49.163: E/Unity(6127): BATCH_TO_SPACE_ND: Operation is not supported.
08-07 10:50:49.163: E/Unity(6127): SPACE_TO_BATCH_ND: Operation is not supported.
08-07 10:50:49.163: E/Unity(6127): 53 operations will run on the GPU, and the remaining 31 operations will run on the CPU.
08-07 10:50:49.163: E/Unity(6127): TensorFlowLite.Interpreter:TfLiteInterpreterCreate(IntPtr, IntPtr)
08-07 10:50:49.163: E/Unity(6127): TensorFlowLite.Interpreter:.ctor(Byte[], InterpreterOptions)
08-07 10:50:49.163: E/Unity(6127): TensorFlowLite.BaseImagePredictor`1:.ctor(String, Boolean)
08-07 10:50:49.163: E/Unity(6127): TensorFlowLite.DeepLab:.ctor(String, ComputeShader)
08-07 10:50:49.163: E/Unity(6127): DeepLabSample:Start()

いくつかのオペレーションがGPUに対応していないためのエラーのようです。
ということで、以下の部分をfalseにして(GPU未使用にして)ビルドし直してみます。
ちなみにiOSではGPUオンの状態でも問題なく動いたのでAndroid版の問題のようです

public DeepLab(string modelPath, ComputeShader compute) : base(modelPath, true)
{
    // ... 後略

これを、

public DeepLab(string modelPath, ComputeShader compute) : base(modelPath, false)
{
    // ... 後略

こうします。
この第二引数がGPUを使うかどうかのフラグの指定になっています。

あとはこれをビルドし直して動かすだけです。

youtu.be

(すでに冒頭でも載せていますが)これで無事に動きました!

最後に

これがゴールではなくむしろスタート地点です。
ここから、独自のモデルの訓練をして目的に適合する結果を得られるように調整していかなければなりません。

が、ひとまずはTensorFlowで作られたモデルをtflite形式に変換して動作させるところまで確認できたので、あとはこの上に追加して作業をしていく形になります。

ここまで来るのは長かった・・・。
やっと本題に入れそうです。

Oculus Questでハンドトラッキングを使ってみる

概要

Oculus Questのハンドトラッキングが利用できるようになったので使ってみたいと思います。
そこで、実際に使用するにあたってセットアップ方法とどういう情報が取れるのか、どう使えるかなどをまとめておきます。

ドキュメントは以下です。

developer.oculus.com

ちなみにドキュメントにも注意書きが書かれていますが、Oculus Linkでのハンドトラッキングは開発用でのみ動作するようです。

注:Oculus QuestとOculus Linkを使用する場合、PC上でのハンドトラッキングの使用はUnityエディターでサポートされています。この機能は、Oculus Quest開発者の反復時間短縮のため、Unityエディターでのみサポートされています。


Table of Contents


セットアップ

まずはプロジェクトをセットアップしていきます。

ここはHand Gesture用ではなく普通のOculusのセットアップです。
なのですでに知っている方は読み飛ばしてもらって大丈夫です。

XR Managementのインストール

Oculusを利用する場合は、Package ManagerからXR Managementをインストールします。

インストールすると、Project SettingsにXR Managementの項目が追加されるので、そこからOculus用のPluginをインストールします。

こちらもインストールが終わると画面が以下のように変化するので、Plugin ProvidersOculus Loaderを追加します。

※ ちなみに、Unityエディタ上でOculus Linkを使って開発を行う場合はPC向けにもOculus Loaderを設定する必要があります。

Oculus Integrationをインポート

次に、Unity Asset StoreからOculus Integrationをインポートします。

インポートが終わったらOVRCameraRigをシーン内に配置します。
その際、シーン内のカメラと重複するので元々あったほうを削除します。

配置したらOVRCameraRigにアタッチされているOVR ManagerHand Tracking SupportのリストからControllers and Handsを選択します。

※ ちなみにPlatformの設定がAndroidなっていないと設定できないようなので注意してください。

ハンドトラッキングを有効にする

ハンドトラッキングの機能を利用するためにはOculus Quest本体側の設定も必要になります。
ドキュメントから引用すると以下のように設定します。

ユーザーが仮想環境で手を使用するには、Oculus Quest上でハンドトラッキング機能を有効にする必要があります。

Oculus Questで、[Settings(設定)] > [Device(デバイス)]に移動します。 トグルボタンをスライドすることによってハンドトラッキング機能を有効にします。 手とコントローラーの使用を自動で切り替えられるようにするには、トグルボタンをスライドすることによって手またはコントローラーの自動有効化機能を有効にします。

シーンへの手の追加

ドキュメントによると、

手を入力デバイスとして使用するには、手動でシーンに追加する必要があります。手はOVRHandPrefab prefaにより実装されています。

とのことなので、対象のPrefabを配置します。
対象のPrefabはOculus/VR/Prefabsにあります。

それを、シーンに配置したOVRCameraRig以下のLeftHandAnchorRightHandAnchorの下に配置します。

手のタイプを設定する

配置したOVRHandPrefabは両手用になっているので、以下の3つのコンポーネントの設定を適切な手のタイプ(左手 or 右手)に変更します。

  • OVRHand
  • OVRSkeleton
  • OVRMesh

以上でセットアップは終了です。
あとはOculus Linkでつないで再生ボタンを押すと以下のように手が表示されるようになります。

※ バージョンによってはこれで正常に動作しない場合があるかもしれません。その場合はOculus Integrationを最新にしてみてください。

ハンドトラッキングによるデータの取得

セットアップが終わったので、あとはハンドトラッキングシステムから得られるデータを用いて様々なコンテンツを作っていくことができます。
ここではいくつかのデータの取得方法をまとめておこうと思います。

各指のピンチ強度を測る

OVRHandには各指のピンチ強度(*)を測るAPIがあるので簡単に測ることができます。

  • ... ピンチ強度は各指が『親指とどれくらい近づいているかを測る値』です。曲がり具合ではないので注意です。
float thumbStr = _rightHand.GetFingerPinchStrength(OVRHand.HandFinger.Thumb);
float indexStr = _rightHand.GetFingerPinchStrength(OVRHand.HandFinger.Index);
float middleStr = _rightHand.GetFingerPinchStrength(OVRHand.HandFinger.Middle);
float ringStr = _rightHand.GetFingerPinchStrength(OVRHand.HandFinger.Ring);
float pinkyStr = _rightHand.GetFingerPinchStrength(OVRHand.HandFinger.Pinky);

このあたりのデータを組み合わせれば、簡単なジェスチャーなどは検知できそうですね。

ボーン情報を取得する

ボーンの各情報を得るためにはOVRSkeletonクラスを利用します。
OVRSkeletonにはOVRBone構造体を保持するリストがあり、そこから情報を取り出します。

リストのどこにどのボーン情報が入っているかはOVRSkeleton.BoneId enumによって定義されており、それをintに変換して利用します。

なお、どこにどの情報が入っているかはドキュメントに記載されています。引用すると以下のように定義されています。

Invalid          = -1
Hand_Start       = 0
Hand_WristRoot   = Hand_Start + 0 // root frame of the hand, where the wrist is located
Hand_ForearmStub = Hand_Start + 1 // frame for user's forearm
Hand_Thumb0      = Hand_Start + 2 // thumb trapezium bone
Hand_Thumb1      = Hand_Start + 3 // thumb metacarpal bone
Hand_Thumb2      = Hand_Start + 4 // thumb proximal phalange bone
Hand_Thumb3      = Hand_Start + 5 // thumb distal phalange bone
Hand_Index1      = Hand_Start + 6 // index proximal phalange bone
Hand_Index2      = Hand_Start + 7 // index intermediate phalange bone
Hand_Index3      = Hand_Start + 8 // index distal phalange bone
Hand_Middle1     = Hand_Start + 9 // middle proximal phalange bone
Hand_Middle2     = Hand_Start + 10 // middle intermediate phalange bone
Hand_Middle3     = Hand_Start + 11 // middle distal phalange bone
Hand_Ring1       = Hand_Start + 12 // ring proximal phalange bone
Hand_Ring2       = Hand_Start + 13 // ring intermediate phalange bone
Hand_Ring3       = Hand_Start + 14 // ring distal phalange bone
Hand_Pinky0      = Hand_Start + 15 // pinky metacarpal bone
Hand_Pinky1      = Hand_Start + 16 // pinky proximal phalange bone
Hand_Pinky2      = Hand_Start + 17 // pinky intermediate phalange bone
Hand_Pinky3      = Hand_Start + 18 // pinky distal phalange bone
Hand_MaxSkinnable= Hand_Start + 19
// Bone tips are position only. They are not used for skinning but are useful for hit-testing.
// NOTE: Hand_ThumbTip == Hand_MaxSkinnable since the extended tips need to be contiguous
Hand_ThumbTip    = Hand_Start + Hand_MaxSkinnable + 0 // tip of the thumb
Hand_IndexTip    = Hand_Start + Hand_MaxSkinnable + 1 // tip of the index finger
Hand_MiddleTip   = Hand_Start + Hand_MaxSkinnable + 2 // tip of the middle finger
Hand_RingTip     = Hand_Start + Hand_MaxSkinnable + 3 // tip of the ring finger
Hand_PinkyTip    = Hand_Start + Hand_MaxSkinnable + 4 // tip of the pinky
Hand_End         = Hand_Start + Hand_MaxSkinnable + 5
Max              = Hand_End + 0

なお、以下の記事から画像を引用させていただくと、各IDの割り振りはこんな感じになるようです。

Finger's ID

qiita.com

簡単なハンドコントロール

OVRHandは、ホーム画面などで利用される手と同じ機能を簡単に利用するためのAPIを提供してくれています。
その情報にアクセスするにはOVRHand.PointerPoseプロパティを利用します。

これはTransform型で、手をポインタとして見た場合の位置と回転を提供してくれます。

視覚化してみたのが以下の動画です。

youtu.be

個人的にはやや直感に反する挙動だなと思っています。
手の指の向きは参考にされていないようで、基本的にポインタ方向は『手の高さ』によって算出されているような印象を受けます。

手のひらの法線を計算する

今回、個人プロジェクトで『手のひらの法線』が必要になり、それを求めるプログラムを書いたので参考までに載せておきます。

using System.Collections;
using System.Collections.Generic;

using UnityEngine;

namespace Conekton.ARUtility.Input.Application
{
    public class HandPoseController : MonoBehaviour
    {
        [SerializeField] private OVRHand _targetHand = null;
        [SerializeField] private OVRSkeleton _rightSkeleton = null;
        [SerializeField] private Transform _palmNormalTrans = null;
        [SerializeField] private float _detectLimit = 0.5f;

        private OVRSkeleton.BoneId[] _forPalmCalcTargetList = new[]
        {
            // First two of them are used for calculating palm normal.
            OVRSkeleton.BoneId.Hand_Index1,
            OVRSkeleton.BoneId.Hand_Pinky0,

            OVRSkeleton.BoneId.Hand_Middle1,
            OVRSkeleton.BoneId.Hand_Ring1,
            OVRSkeleton.BoneId.Hand_Pinky1,
            OVRSkeleton.BoneId.Hand_Thumb0,
        };

        private OVRSkeleton.BoneId BoneIDForNormalCalculation1 => OVRSkeleton.BoneId.Hand_Index1;
        private OVRSkeleton.BoneId BoneIDForNormalCalculation2 => OVRSkeleton.BoneId.Hand_Pinky0;

        public bool TryGetPositionAndNormal(out Vector3 position, out Vector3 normal)
        {
            if (!_targetHand.IsTracked)
            {
                position = Vector3.zero;
                normal = Vector3.up;
                return false;
            }

            Vector3 center = Vector3.zero;

            foreach (var id in _forPalmCalcTargetList)
            {
                OVRBone bone = GetBoneById(id);
                center += bone.Transform.position;
            }

            center /= _forPalmCalcTargetList.Length;

            position = center;

            OVRBone bone0 = GetBoneById(BoneIDForNormalCalculation1);
            OVRBone bone1 = GetBoneById(BoneIDForNormalCalculation2);

            Vector3 edge0 = bone0.Transform.position - center;
            Vector3 edge1 = bone1.Transform.position - center;

            normal = Vector3.Cross(edge0, edge1).normalized;

            return true;
        }

        private OVRBone GetBoneById(OVRSkeleton.BoneId id)
        {
            return _rightSkeleton.Bones[(int)id];
        }
    }
}

考え方はシンプルで、ハンドトラッキングから得られるボーンの位置をいくつか選び、それらの平均の位置を手のひらの位置としています。
また法線については、求めた手のひらの位置とふたつのボーンの位置との差分ベクトルを取り、それの外積を取ることで求めています。

まとめ

Oculus Questのハンドトラッキングの精度は驚異と言っていいと思います。

過去に、Leapmotionを使ったコンテンツを開発したことがありますが、Leapmotionよりも精度が高い印象です。(それ用デバイスより精度が高いって・・)
実際に体験すると、本当にVR内に自分の手があるかのように思えるくらいなめらかにトラッキングしてくれます。

ハンドトラッキングを用いたUI/UXはさらに発展していくと思うので、これからとても楽しみです。

Unityの推論エンジン『Barracuda』を試してみたのでそのメモ

概要

以下の記事を参考に、最近リリースされたUnity製推論エンジンを試してみたのでそのメモです。

qiita.com

note.com

Barracudaとは?

Barracudaとは、ドキュメントにはこう記載されています。

Barracuda is lightweight and cross-platform Neural Net inference library. Barracuda supports inference both on GPU and CPU.

軽量でクロスプラットフォームニューラルネットワークの推論ライブラリということですね。
そしてこちらのブログによるとUnity製のオリジナルだそうです。

セットアップ

BarracudaPackage Managerから簡単にインストールできます。
インストールするにはWindow > Package ManagerからBarracudaを選択してインストールするだけです。

モデルの準備

これでC#からBarracudaを利用する準備は終わりです。
しかし、Deep Learningを利用して処理を行うためには訓練済みのモデルデータが必要です。
これがなければDeep Learningはなにも仕事をしてくれません。

Barracudaで扱えるモデル

Deep Learningを利用するためのフレームワークは多数出ています。有名どころで言えばTensorFlowなどですね。
そしてこうしたフレームワークごとにフォーマットがあり、そのフレームワークで訓練したデータは独自の形式で保存されます。
つまり言い換えればフレームワークごとにデータフォーマットが異なるということです。

そしてBarracudaでもそれ用のデータ・フォーマットで保存されたモデルデータが必要になります。
しかしBarracudaではONNX形式のモデルデータも扱うことができるようになっています。

ONNXとは?

ONNXはOpen Neural Network eXchangeの略です。(ちなみに『オニキス』と読むらしいです)
Openの名がつく通り、Deep Learningのモデルを様々なフレームワーク間で交換するためのフォーマット、ということのようです。

前述の通りBarracudaでも利用できます。

ONNXモデルの配布サイト

以下のリポジトリからいくつかの学習済みモデルがDownloadできます。

github.com

モデルの変換

Barracudaでは主要なフレームワークのモデルからBarracuda形式およびONNX形式に変換するためのツールを提供してくれています。 

note.com

詳細は上記の記事を見てもらいたいと思いますが、どういう感じで変換を行うのかのコマンド例を載せておきます。

$ python tensorflow_to_barracuda.py ../mobilenet_v2_1.4_224_frozen.pb ../mobilenet_v2.nn

Converting ../mobilenet_v2_1.4_224_frozen.pb to ../mobilenet_v2.nn
Sorting model, may take a while...... Done!
IN: 'input': [-1, -1, -1, -1] => 'MobilenetV2/Conv/BatchNorm/FusedBatchNorm'
OUT: 'MobilenetV2/Predictions/Reshape_1'
DONE: wrote ../mobilenet_v2.nn file.

モデルを利用して推論する(Style Chnage)

冒頭で紹介したこちらの記事からStyle Changeの方法を見ていきます。
(これがおそらく一番短くて分かりやすい例だと思います)

using UnityEngine;
using Unity.Barracuda;

public class StyleChange : MonoBehaviour
{
    [SerializeField] private NNModel _modelAsset = null;
    [SerializeField] private RenderTexture _inputTexture = null;
    [SerializeField] private RenderTexture _outputTeture = null;

    private Model _runtimeModel = null;
    private IWorker _worker = null;

    private void Start()
    {
        // Load an ONNX model.
        _runtimeModel = ModelLoader.Load(_modelAsset);

        // Create a worker.
        // WorkerFactory.Type means which CPU or GPU prefer to use.
        _worker = WorkerFactory.CreateWorker(WorkerFactory.Type.Compute, _runtimeModel);
    }

    private void Update()
    {
        Tensor input = new Tensor(_inputTexture);
        Inference(input);
        input.Dispose();
    }

    private void Inference(Tensor input)
    {
        _worker.Execute(input);
        Tensor output = _worker.PeekOutput();
        output.ToRenderTexture(_outputTeture, 0, 0, 1/255f, 0, null);
        output.Dispose();
    }

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

だいぶ短いコードですね。これで映像を変換できるのだから驚きです。
ただし、複雑なネットワークを使っている場合はその分処理が重くなるので、リアルタイムなポストエフェクトとしては利用できないと思います。

このコードを実行すると以下のような結果が得られます。
(もちろん、設定するモデルによって出力の絵は変わります)

InputにRenderTextureを与え、OutputもRenderTextureで受け取っていますね。
入出力ともに画像なので利用イメージがしやすいと思います。

しかし(書いておいてなんですが)こうしたスタイルの変更というのはゲームではあまり使用されないかもしれません。
それよりも、物体検知やセグメンテーションなどでその真価を発揮するのではないかなと思っています。

ということで、次は物体検知についても書いておきます。

モデルを利用して推論する(物体検知)

以下のコードはこちらのGitHubのものを参考にさせていただいています。
ファイルへの直リンク

using System;
using Barracuda;
using System.Linq;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;

public class Classifier : MonoBehaviour
{
    public NNModel modelFile;
    public TextAsset labelsFile;

    public const int IMAGE_SIZE = 224;
    private const int IMAGE_MEAN = 127;
    private const float IMAGE_STD = 127.5f;
    private const string INPUT_NAME = "input";
    private const string OUTPUT_NAME = "MobilenetV2/Predictions/Reshape_1";

    private IWorker worker;
    private string[] labels;


    public void Start()
    {
        this.labels = Regex.Split(this.labelsFile.text, "\n|\r|\r\n")
            .Where(s => !String.IsNullOrEmpty(s)).ToArray();
        var model = ModelLoader.Load(this.modelFile);
        this.worker = WorkerFactory.CreateWorker(WorkerFactory.Type.ComputePrecompiled, model);
    }


    private int i = 0;
    public IEnumerator Classify(Color32[] picture, System.Action<List<KeyValuePair<string, float>>> callback)
    {
        var map = new List<KeyValuePair<string, float>>();

        using (var tensor = TransformInput(picture, IMAGE_SIZE, IMAGE_SIZE))
        {
            var inputs = new Dictionary<string, Tensor>();
            inputs.Add(INPUT_NAME, tensor);  
            var enumerator = this.worker.ExecuteAsync(inputs);

            while (enumerator.MoveNext())
            {
                i++;
                if (i >= 20)
                {
                    i = 0;
                    yield return null;
                }
            };

            // this.worker.Execute(inputs);
            // Execute() scheduled async job on GPU, waiting till completion
            // yield return new WaitForSeconds(0.5f);

            var output = worker.PeekOutput(OUTPUT_NAME);

            for (int i = 0; i < labels.Length; i++)
            {
                map.Add(new KeyValuePair<string, float>(labels[i], output[i] * 100));
            }
        }

        callback(map.OrderByDescending(x => x.Value).ToList());
    }


    public static Tensor TransformInput(Color32[] pic, int width, int height)
    {
        float[] floatValues = new float[width * height * 3];

        for (int i = 0; i < pic.Length; ++i)
        {
            var color = pic[i];

            floatValues[i * 3 + 0] = (color.r - IMAGE_MEAN) / IMAGE_STD;
            floatValues[i * 3 + 1] = (color.g - IMAGE_MEAN) / IMAGE_STD;
            floatValues[i * 3 + 2] = (color.b - IMAGE_MEAN) / IMAGE_STD;
        }

        return new Tensor(1, height, width, 3, floatValues);
    }
}

ちなみにStartメソッドで読み込んでいるテキストは分類の名称が改行区切りで入っているただのテキストファイルです。
以下はその一部。(ファイルへの直リンク

background
tench, Tinca tinca
goldfish, Carassius auratus
great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias
tiger shark, Galeocerdo cuvieri
hammerhead, hammerhead shark
electric ray, crampfish, numbfish, torpedo
stingray
cock
hen
...

入力画像から識別したラベルに対する確率を取得する

これらの動作の基本的な流れは以下です。

  1. 画像をテンソル化して入力データとする
  2. ニューラルネットワークを通して出力を得る
  3. 出力はラベル数と同じ数の1階テンソルで、値はそれぞれの分類の確度(%)
  4. あとは確度に応じて望む処理をする

という具合です。
そしてその出力部分を抜粋すると以下のようになっています。

var output = worker.PeekOutput(OUTPUT_NAME);

for (int i = 0; i < labels.Length; i++)
{
    map.Add(new KeyValuePair<string, float>(labels[i], output[i] * 100));
}

output[i] * 100としている箇所が確率を%に変換している部分ですね。
つまり、該当番号(i)のラベルである確率をDictionary型の変数に入れて返しているというわけです。

取得するテンソルvar output = worker.PeekOutput(OUTPUT_NAME);でアクセスしています。
そしてこのOUTPUT_NAMEprivate const string OUTPUT_NAME = "MobilenetV2/Predictions/Reshape_1";と定義されています。

この文字列は訓練されたモデルのネットワークの変数名でしょう。
つまり推論の結果をこれで取得している、というわけですね。

モデルのInput / Outputを確認する

前述したように、モデルへのInputとOutputを明示的に指定する必要があり、そのための文字列を知る必要があります。

そのための情報はインスペクタから確認することができます。
インポートしたモデルファイルを選択すると以下のような情報が表示されます。

この図のInputs (1)Outputs (1)がそれに当たります。
その下のLayersニューラルネットワークのレイヤーの情報です。
つまりどんなネットワークなのか、ということがここで見れるわけですね。

画像として出力されるモデルかどうか確認する

ちなみにStyle Changeのところで書いたように、画像自体を変換して出力するネットワークなのかどうかは以下のInputs / Outputsを確認すると分かると思います。

入力が画像サイズで、出力も画像サイズになっている場合はそれが画像として出力されていると見ることが出来ます。
出力が[1, 224, 224, 3]となっているのは224 x 224サイズ3チャンネル(RGB)1つ出力することを意味しているわけですね。

まとめ

Deep Learningニューラルネットワーク)は、極論で言えば巨大な関数であると言えると思います。
入力となる引数も巨大であり、そこから目的の出力を得ること、というわけですね。


y = f(x)

の、 xが入力(つまり今回の場合は画像)で、その結果(どのラベルの確率が高いか)が y、というわけです。

これを上記の物体検知に当てはめてC#で書き直すと、

// inferenceは「推論」を意味する英単語
Dictionaly<string, float> map = Inference(_inputTexture);

という感じですね。

最近はDeep Learningについてずっと調査をしています。

そこでの自分の大まかな理解を書いておくと、この巨大な関数を解くためのパラメータを『機械学習』で調整させる。
その調整する方法が『パーセプトロン』をベースとする『ニューラルネットワーク』を利用して行っている。

そしてこの調整されたパラメータとネットワーク構成がつまりは訓練済みモデル(データ)というわけです。
なのでそのパラメータを利用した関数を通すとなにかしらの意味がある出力が得られる、というわけなんですね。

もちろん、そのネットワークをどう組むか。それがどう実現しているのか。基礎を学ぼうとすると膨大な知識が必要になります。

しかし、こと利用する視点だけで見ればなんのことはない、ただの関数実行だ、と見ると利用がしやすいのではないかと思います。

これを機に色々とUnity上でディープラーニングを使って色々とやっていきたいと思います。