e.blog

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

Unity VFX Graphでposition mapを使って導蟲風パーティクルを作る

概要

今回はVFX Graphを使ってパーティクルをMeshにまとわりつかせてモンハンワールドの導蟲風パーティクルを実装してみました。

実際に実行するとこんな感じになります↓

モンハンワールドの導蟲のようにモデルにまとわりついていく様が見れるかと思います。

今回はこれを実装した内容をまとめていきます。

今回のサンプルはGitHubにアップしてあるので、動くものを確認したい人はそちらをご覧ください。

github.com

Meshの頂点をテクスチャにベイクする

パーティクルをMeshにまとわりつかせるためにMeshの頂点をテクスチャにベイクし、それをランタイムで切り替えるという方法で実装しました。

VFX GraphにはPoint Cacheを作るツールもあるのですが、それだとランタイムでVFX Graphに送ることができないのでこうしました。

Meshの頂点のベイクには以下の記事を参考にさせていただきました。

note.com

上記記事を参考に処理を追っていきましょう。
まずPoint CacheのアセットがインポートされるとPointCacheImpoterがそれをフックしアセットの内容をもとに適切にテクスチャを生成します。

(インポーターのファイルの場所は以下)

【ソースの場所】
Packages/Visual Effect Graph/Editor/Utilities/pCache/Impoter/PointCacheImpoter.cs

つまり、Point Cacheは最終的にテクスチャとして保持されている、ということです。

テクスチャの内容

適切にテクスチャを生成すると書きましたが、どういうデータがテクスチャに格納されるのでしょうか。
上記のインポーターのコードを追うことでそれを確認することができます。
が、今回は前述の記事を参考にさせていただきました。

実はテクスチャに保存されているのは、Meshの頂点位置そのものです。つまり(x, y, z)ですね。
なのでその値をそのままテクスチャに保存しておけばいいということになります。(Colorも(r, g, b, a)の4要素を扱えるので、(x, y, z)の3要素は問題なく使える)

ただテクスチャのフォーマットは重要です。通常、色にはマイナス値はありません。そのため、テクスチャのフォーマットによってはマイナス値を保存できないものもあります。

なので以下のようにフォーマットを指定してテクスチャを生成する必要があります。

Texture2D tex = new Texture2D(width, height, TextureFormat.RGBAFloat, false);

Texture2Dに保存したPosition Mapをサンプリングする

Meshの頂点をテクスチャにベイクしたら次はそれを読み出します。
通常、Set **** from Mapブロックを利用する場合はパーティクルIDからよしなにサンプル位置を計算して頂点位置を取得してくれますが、これだと頂点位置がローカル座標として扱われてしまうため今回の動画のように、各モデルにまとわりつかせるということができません。

なので今回は自前で作成したグラフを用いてテクスチャをサンプリングし、それをTarget Positionに設定します。
ですが、テクスチャからどうやってサンプリングしたらいいのでしょうか?

実は幸いなことに、VFX Graphで生成されるコード断片を見ることができるのでそれを参考にノードを組みました。

ただコード断片を見るには設定でDebugを有効化しないとなりません。

PreferencesのShow Additional Debug infoのチェックをオンにすることで確認できるようになります。

すると以下のようにインスペクタにコード断片が表示されるようになります。

文字としてコードも抜粋しておきます。

uint width, height;
attributeMap.t.GetDimensions(width, height);
uint count = width * height;
uint id = particleId % count;
uint y = id / width;
uint x = id - y * width;
float3 value = (float3)attributeMap.t.Load(int3(x, y, 0));
value = (value  + valueBias) * valueScale;
targetPosition = value;

これを実現するSubgraph Operatorを作ってみました。

このSubgraphを実際に使うとこんな感じになります。

SampleMapと書かれたオペレータが作ったSubgraphです。
これで汎用的にテクスチャから位置を取ることができるようになりました。

ターゲット位置に徐々に近づける

VFX GraphにはSet Target PositionというBlockがあります。
最初、これはどうやって使うんだろうと思っていたんですが生成されたコードを見て理解しました。

これはなんのことはない、パーティクルのイチ要素として値を設定しているだけでした。
つまりこの値をどう使うかは実装者次第、というわけなんですね。

ということで、パーティクルをターゲット位置に近づける処理は、毎フレームごとに徐々にターゲット位置に近づくように位置を更新する、というグラフを組みます。

実際に作成したグラフは以下です。

Get Attribute: targetPosition (Current)から設定されたターゲットポジションを取得します。
そしてパーティクルの1フレーム前の位置との差分を取り、ターゲット位置方向へのベクトルを得ます。

今回は導蟲風にするためにTurbulenceで虫のようなゆらぎを加えつつ、徐々にターゲットに近づけていく、というふうにしました。

また近づいた際も、ピッタリとターゲット位置に張り付くのではなく若干ゆらぎを残すようにしています。
これのおかげで虫が対象にまとわりついているような見た目になりました。

ランタイムにデータをVFX Graphに送る

これでVFX Graphの準備ができました。あとは設定したパラメータに対してC#側から値を送ってやれば完成です。

まずはMeshの頂点位置をベイクするスクリプトから見てみましょう。

using UnityEngine;
using UnityEngine.VFX;

[RequireComponent(typeof(VisualEffect))]
public class BakeMeshToTexture : MonoBehaviour
{
    [SerializeField] private GameObject _target = null;

    public Texture2D BakedTexture { get; private set; }
    public Transform Transform => _target.transform;

    private SkinnedMeshRenderer _skinnedMeshRenderer = null;
    private Mesh _mesh = null;
    private bool _isSkinnedMesh = false;
    private Color[] _colorBuffer = null;

    public void Initialize()
    {
        if (_target.TryGetComponent(out MeshFilter filter))
        {
            _mesh = filter.mesh;
        }

        if (_target.TryGetComponent(out _skinnedMeshRenderer))
        {
            _isSkinnedMesh = true;
            _mesh = new Mesh();
            BakeMesh();
        }

        Vector3[] vertices = _mesh.vertices;
        int count = vertices.Length;

        float r = Mathf.Sqrt(count);
        int size = (int)Mathf.Ceil(r);

        _colorBuffer = new Color[size * size];

        BakedTexture = new Texture2D(size, size, TextureFormat.RGBAFloat, false);
        BakedTexture.filterMode = FilterMode.Point;
        BakedTexture.wrapMode = TextureWrapMode.Clamp;

        UpdatePositionMap();
    }

    public void UploadMeshTexture()
    {
        if (_isSkinnedMesh)
        {
            BakeMesh();
            UpdatePositionMap();
        }
    }

    private void BakeMesh()
    {
        _skinnedMeshRenderer.BakeMesh(_mesh);
    }

    private void UpdatePositionMap()
    {
        int idx = 0;
        foreach (Vector3 vert in _mesh.vertices)
        {
            _colorBuffer[idx] = VectorToColor(vert);
            idx++;
        }

        BakedTexture.SetPixels(_colorBuffer);
        BakedTexture.Apply();
    }

    private Color VectorToColor(Vector3 v)
    {
        return new Color(v.x, v.y, v.z);
    }
}

MeshFilterの場合とSkinnedMeshRendererの場合で処理を分けていますが、基本的にはMeshを取り出してその頂点をテクスチャのピクセルに保存しているだけです。

位置とカラーの変換も以下のようにそのまま値を変換しているだけですね。

private Color VectorToColor(Vector3 v)
{
    return new Color(v.x, v.y, v.z);
}

これで頂点位置をベイクすることができました。あとはこれをVFX Graphに送ってやれば冒頭の動画のエフェクトの完成です。
送っている側は非常にシンプルなので、切り替えている部分だけ抜粋します。

private void Change()
{
    _index = (_index + 1) % _bakers.Length;

    _vfx.SetTexture("PositionMap", CurrentBaker.BakedTexture);
    _vfx.SetInt("Size", CurrentBaker.BakedTexture.width);
    _vfx.SetMatrix4x4("VolumeTransform", CurrentBaker.Transform.localToWorldMatrix);
}

_vfx変数はVisualEffectコンポーネントです。これに各種値を送るメソッドが定義されているのでそれを利用して値を送っているだけです。

まとめ

VFX Graph、慣れてくるとわりと簡単に思った通りの絵が作れるので楽しいですね。
今回のエフェクトはKeijiroさんのUniteのときの動画を見ていたときに思いつきました。

特に、Custom Attributeを使って値を操作するあたりを見てTarget Positionをどう使うのかが分かりました。
グラフという形に惑わされて「勝手にこういうことをやってくれるものだろう」という思い込みがあったのです。

しかしそこがそもそも間違いだということに気づけたのはとても大きな収穫でした。
Compute Shaderを使ってパーティクルを操作したことがある人は、各Blockが{}で囲まれたイチコードに変換されているに過ぎない、ということが分かれば簡単にエフェクトを作ることができるようになると思います。

VFX GraphのConform to Signed Distance Fieldのコードを読んでみる

概要

そのVFX Graphの中にConform to Signed Distance FieldというBlockがあります。

SDFを活用することで表現力が向上するので、今回はそのコードを読んでSigned Distance Field、SDFがどういうふうに使われているのかを見ていきたいと思います。

実際に実行するとこんな感じでパーティクルがSDFの形状に変わります。

ちなみにSDFファイル自体にどういう情報が格納されているのかというのは以下の記事を参考にさせていただきました。

qiita.com

最新版VFX GraphにはSDFベイクツールがある

また、最新のVisual Effect GraphにはこのSDFファイルを生成するツールが含まれています。これら新機能についての記事をUnity for Proへ寄稿させていただいたのでよければこちらもご覧ください。

forpro.unity3d.jp



VFX Graphが生成したコードを確認する

VFX Graphファイルを生成し、グラフを作成するとそれに準じたコードが自動生成されます。
今回はこれをエディタで開いて見ていくことにします。

VFX Graphのファイルの左横に「▼」マークがあるのでこれを開くと以下のように、自動生成されたシェーダが表示されるので、これをダブルクリックすることで普通にエディタで開くことができます。

特に今回はUpdateの内容を確認したいので一番下の「[GenerateDynamicSignedDistanceField] [System] Update Particle」を開いて確認していきます。

VFX GraphはGPU上で動作するため、各パーティクルのUpdateはCompute Shaderによって行われます。
Compute Shader自体の説明はここでは割愛します。興味がある方は以前に書いた以下の記事を参考にしてください。

edom18.hateblo.jp

edom18.hateblo.jp

Update Particleのカーネル

早速見ていきましょう。生成されたシェーダのカーネルは新規作成したときと同じようにCSMainです。
コードを見ていくとConformToSDFという関数が呼ばれている箇所がありました。これがきっと更新処理を行っているところでしょう。

呼び出し部分のコードを見ると以下のようになっています。

ConformToSDF( /*inout */attributes.velocity, attributes.position, attributes.mass, GetVFXSampler(DistanceField_a, samplerDistanceField_a), InvFieldTransform_a, FieldTransform_a, attractionSpeed_a, attractionForce_a, stickDistance_a, stickForce_a, deltaTime_a);

引数が多いですが、名前を見ればなんとなくどういう情報が使われているか分かるかと思います。

ConformToSDF関数を読み解く

ConformToSDF関数を詳しく見ていきましょう。
詳細についてはコード自体にコメント形式で注釈を加えました。

void ConformToSDF(inout float3 velocity, float3 position, float mass, VFXSampler3D DistanceField, float4x4 InvFieldTransform, float4x4 FieldTransform, float attractionSpeed, float attractionForce, float stickDistance, float stickForce, float deltaTime)
{
    // パーティクルのワールド位置をローカル位置に変換する
    float3 tPos = mul(InvFieldTransform, float4(position,1.0f)).xyz;

    // -0.5 ~ 0.5 を 0.0 ~ 1.0 に変換している
    float3 coord = saturate(tPos + 0.5f);

    // 3Dテクスチャからサンプリング
    float dist = SampleSDF(DistanceField, coord);
    
    // ローカル位置の絶対値(のちの判定をしやすくしている)
    float3 absPos = abs(tPos);

    // 各軸に対して一番遠い位置を取得
    // = -0.5 ~ 0.5範囲のAABBに対して外に出ているかチェックするための値
    float outsideDist = max(absPos.x,max(absPos.y,absPos.z));

    float3 dir;

    // AABBの内か外かの判定。-0.5 ~ 0.5の範囲かつ絶対値判定なので
   // 0.5より上ならAABB範囲外と判定できる
    if (outsideDist > 0.5f) // Check wether point is outside the box
    {
        // in that case just move towards center
        // 範囲外の場合はたんに中心に向かって進む
        dist += outsideDist - 0.5f;

        // FieldTransformの4カラム目の3要素=translateの内容なのでVFX Effect自体のワールド座標
        dir = normalize(float3(FieldTransform[0][3],FieldTransform[1][3],FieldTransform[2][3]) - position);
    }
    else
    {
        // compute normal
        // 法線の計算
        // 偏微分を用いて距離ベクトルの勾配を求め、その方向へ移動させる
        dir = SampleSDFDerivativesFast(DistanceField, coord, dist);

        // distが0以上なら向きを反転させる
        if (dist > 0)
            dir = -dir;

        // ワールド座標に変換した上で正規化する
        dir = normalize(mul(FieldTransform,float4(dir,0)).xyz);
    }
    
    // SDFの表面までの距離
    float distToSurface = abs(dist);
    
    // SDF方向と速度の内積
    // 目的は、下で計算しているターゲットスピード(tgtSpeed)に「達しているか」を求める。
    // 例えば、向かう方向(dir)に対して平行でかつターゲットスピードに達していれば
    // deltaSpeedは0になり、加速の必要がないことが示される。
    float spdNormal = dot(dir,velocity);

    // 距離に応じてsmoothstepを掛ける
    // 以下の計算で、距離に応じてAttraction ForceとStick Forceどちらを使うかを求めるのに利用する。
    // またターゲットスピードの比率としても用いられる。
    float ratio = smoothstep(0.0,stickDistance * 2.0,abs(distToSurface));

    // sign = -1.0 or 0.0 or 1.0に変換。そして絶対値を取っているので実質0.0 or 1.0に変換(=これを乗ずることで向きが決まる)
    // 向き * 引きつける力 * 比率
    float tgtSpeed = sign(distToSurface) * attractionSpeed * ratio;

    // SDFによって引きつけられる力 - 速度による影響度を引く
    float deltaSpeed = tgtSpeed - spdNormal;

    // abs(deltaSpeed)か
    // deltaTime * lerp(stickForce, attractionForce,ratio)
    // の計算結果のうち、小さい値を採用
    // sign(deltaSpeed)は移動すべき方向を示すためのもの?
    // つまり、(引力 * speed * dir) / massを計算している。体積で割ることで加速度を求めている(F = ma)
    // 加速度を速度に加算して終了
    velocity += sign(deltaSpeed) * min(abs(deltaSpeed),deltaTime * lerp(stickForce,attractionForce,ratio)) * dir / mass ;

    // 上記計算のイメージは、向かう方向(dir)に対して垂直より鈍角な方向のvelocity(つまり表面から遠ざかる速度)の場合に引力(attractionForce / stickForce)に引っ張られるような計算。
}

コードはそんなに長くないですね。
いくつかの箇所は想像による部分と、自身がなくて「?」付きになっている箇所があります。
今回はコード自体の大まかな流れを把握するためなのでそこまで深堀りしていません。

SDFファイルの中身

ここは自分もまだしっかり理解しきれていないのですが、もんしょの巣穴さんの記事(DXRで生成するSigned Distance Field)では以下のように記載されていました。

SDF テクスチャを作るのに使用したのは DirectX Raytracing です。 ボリュームテクスチャの1つのVoxelに対して、Voxel中心から複数のレイを飛ばして衝突判定を取り、最接近距離を求めています。 最接近距離ですので絶対値の最小値を利用し、その距離を1~-1の値になるように正規化します。 あるAABB内で最大の距離は対角線の距離になりますが、面倒だったのでAABBの1辺の長さの3倍を今回は用いています。

このことから、(通常は)AABBの対角線の距離で各距離を割ることで正規化を行うようです。
そしてAABBの中心から一番遠い外側は0.5、一番遠い内側は-0.5になります。
(対角線の長さが1なので、中心からは0.5ずつ離れることになる)

これが、0.5を足して補正している理由だと思います。
またAABBの内か外かの判定に0.5より上を指定しているのもそれが理由ですね。

偏微分を用いた力の計算

参考にさせていただいた記事でも言及されていますが、SDFファイル内に保存されている値自体というよりは方向が重要な意味を持っているようです。

言及箇所を引用させていただくと、

Conform to Signed Distance Field BlockとCollide with Signed Distance Field Blockで生成されるコードを見た感じでは、その地点の符号付き距離の値自体というよりもその地点の符号付き距離を微分したベクトル(つまり符号付き距離が0になる地点への最短方向)のほうが大事そうでした。そのベクトルは距離関数が正規化されていなくても求まるはずなので、細かいことは考えずにTexture3Dに符号付き距離を入れればよさそうです

とのこと。

ということで、実際に微分を行っている箇所を見てみると以下のようになっています。

float3 dir;
if (outsideDist > 0.5f) // Check wether point is outside the box
{
    // 中略
}
else
{
    // compute normal
    dir = SampleSDFDerivativesFast(DistanceField, coord, dist);
    // 後略
}

SampleSDFDerivativesFastという関数が呼び出されているのが分かります。
Derivatives微分という意味なのでまさにそれを行っている箇所ですね。

関数は以下のように実装されていました。

float3 SampleSDFDerivativesFast(VFXSampler3D s, float3 coords, float dist, float level = 0.0f)
{
    float3 d;
    // 3 taps
    const float kStep = 0.01f;
    d.x = SampleSDF(s, coords + float3(kStep, 0, 0));
    d.y = SampleSDF(s, coords + float3(0, kStep, 0));
    d.z = SampleSDF(s, coords + float3(0, 0, kStep));
    return d - dist;
}

float SampleSDF(VFXSampler3D s,float3 coords,float level = 0.0f)
{
    return SampleTexture(s,coords,level).x;
}

float4 SampleTexture(VFXSampler3D s,float3 coords,float level = 0.0f)
{
    return s.t.SampleLevel(s.s,coords,level);
}

SampleSDFはただのテクスチャサンプリングです。
サンプル位置(coords)は以下のように計算されています。

float3 tPos = mul(InvFieldTransform, float4(position,1.0f)).xyz;
float3 coord = saturate(tPos + 0.5f);

これを見て分かる通り、パーティクルの位置をローカル座標空間に変換したのちに+0.5fし、-0.5 ~ 0.50.0 ~ 1.0に変換していると思われます。
なのでローカル座標空間で3Dテクスチャをサンプリングしている、というわけですね。

SampleSDFDerivativesFastでは少しずつサンプリング位置をずらして3Dテクスチャをサンプリングしそれを距離と引いた値を返しています。

レイマーチングで使われる「偏微分を用いて法線を求める」ことと同じことをやっていますね。
偏微分は勾配を求めるために使われます。つまり「その方向に進めばゼロに近づくベクトル」を求めていることに他なりません。

そして求めた勾配ベクトルを利用して最終的には加速度を算出し、それを速度に加算しています。

velocity += sign(deltaSpeed) * min(abs(deltaSpeed),deltaTime * lerp(stickForce,attractionForce,ratio)) * dir / mass ;

なので最終的に行っていることは、SDFから勾配ベクトルを求め、その方向に向かって引き寄せられるように速度を調整している、というわけですね。

ちなみに偏微分で勾配ベクトルを求めることがどうして法線につながるのか、というイメージについて以前記事を書いているので興味がある方は読んでみてください。

qiita.com

まとめ

コードをざーっと読んできましたが、一言でなにをしているかを言うと「SDFを利用して勾配ベクトルを求め、その方向にパーティクルを近づける」ということですね。

色々細かい計算が入っていますが、そのあたりについては時間があるときにもう少し深ぼってみようと思います。

その他参考にした記事

colourmath.com marupeke296.com

SRP Batcherが有効なURP向けのシェーダを書く

この記事はUnityアドベントカレンダー2020の5日目の記事です。

qiita.com



概要

以前、URPのScriptableRenderFeatureを使ってブラーをかける方法を解説しました。

edom18.hateblo.jp edom18.hateblo.jp


今回はURP向けのシェーダをどう書くかについて簡単にまとめようと思います。
というのも、以前のビルトインパイプラインのシェーダと異なる部分があり、適切に記述しないとbatchingされないなどの問題があるためです。

ベースにしたのはこちらのUnityのドキュメントに記述されているシェーダです。

抜粋させてもらうと以下のコードになります。

URP用のUnlitなシェーダ

// This shader fills the mesh shape with a color that a user can change using the
// Inspector window on a Material.
Shader "Example/URPUnlitShaderColor"
{    
    // The _BaseColor variable is visible in the Material's Inspector, as a field 
    // called Base Color. You can use it to select a custom color. This variable
    // has the default value (1, 1, 1, 1).
    Properties
    { 
        _BaseColor("Base Color", Color) = (1, 1, 1, 1)
    }

    SubShader
    {        
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalRenderPipeline" }

        Pass
        {            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

            struct Attributes
            {
                float4 positionOS   : POSITION;                 
            };

            struct Varyings
            {
                float4 positionHCS  : SV_POSITION;
            };

            // To make the Unity shader SRP Batcher compatible, declare all
            // properties related to a Material in a a single CBUFFER block with 
            // the name UnityPerMaterial.
            CBUFFER_START(UnityPerMaterial)
                // The following line declares the _BaseColor variable, so that you
                // can use it in the fragment shader.
                half4 _BaseColor;            
            CBUFFER_END

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
                return OUT;
            }

            half4 frag() : SV_Target
            {
                // Returning the _BaseColor value.                
                return _BaseColor;
            }
            ENDHLSL
        }
    }
}

SRP Batcherを有効にする

SRP Batcherを有効にするためにルールがあるのでそれに従います。具体的には以下。

NOTE: To ensure that the Unity shader is SRP Batcher compatible, declare all Material properties inside a single CBUFFER block with the name UnityPerMaterial. For more information on the SRP Batcher, see the page Scriptable Render Pipeline (SRP) Batcher.

要するに、Propertiesブロック内で宣言した値を利用する場合、CBUFFERブロックで囲まないとダメということです。
ドキュメントのサンプルには以下のように書かれています。

CBUFFER_START(UnityPerMaterial)
    half4 _BaseColor;            
CBUFFER_END

逆に言えばこれだけで対応は終了です。

試しに、この対応を入れたものとそうでないものでFrame Debuggerの状態を確認すると以下のように違いが出ます。

まずは対応していないバージョン

そして対応したバージョン

後者はSRP Batchと書かれているのが分かるかと思います。
実際のレンダリングにおいても1パスで3つのオブジェクトがレンダリングされていますね。

上記動画で利用したシェーダは以下です。
参考にしたシェーダにテクスチャを追加しただけのシンプルなものです。

Shader "URPSample/URPUnlit"
{
    Properties
    {
        _BaseColor ("Color", Color) = (1, 1, 1, 1)
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalRenderPipeline" }
        LOD 100

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

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

            struct Attriburtes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            SAMPLER(_MainTex);
            CBUFFER_END

            Varyings vert(Attriburtes IN)
            {
                Varyings OUT;
                UNITY_SETUP_INSTANCE_ID(IN);
                ZERO_INITIALIZE(Varyings, OUT);
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
                OUT.uv = IN.uv;
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                half4 tex = tex2D(_MainTex, IN.uv);
                return tex * _BaseColor;
            }
            ENDHLSL
        }
    }
}

マクロと関数を覗いてみる

ここからはちょっとした興味の内容になるので、詳細に興味がない人はスルーしても大丈夫です。
今回しようしたシェーダで利用されているマクロや関数がどう展開されるのかを覗き見てみようと思います。
(なお、UNITY_VERTEX_INPUT_INSTANCE_IDについては以前の記事で紹介しているので割愛します)

ZERO_INITIALIZE

まずはZERO_INITIALIZEから。
定義を見ると以下のように記述されています。

#define ZERO_INITIALIZE(type, name) name = (type)0;

めちゃシンプルです。特に説明の必要はないでしょうw

CBUFFER_START / CBUFFER_END

次はCBUFFER_STARTCBUFFER_ENDです。
定義は以下のようになっています。

#define CBUFFER_START(name) cbuffer name {
#define CBUFFER_END };

展開すると以下の形になります。

cbuffer UnityPerMaterial {
half4 _BaseColor;
SAMPLER(_MainTex);
};

cbuffer name {}で囲うことをマクロにしているとうわけですね。

ちなみにcbufferconstant bufferの略です。ドキュメントは以下です。

docs.microsoft.com

TransformObjectToHClip

最後はTransformObjectToHClipです。こちらはマクロではなく関数になっています。
定義を見ると以下。

// Transforms position from object space to homogenous space
float4 TransformObjectToHClip(float3 positionOS)
{
    // More efficient than computing M*VP matrix product
    return mul(GetWorldToHClipMatrix(), mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)));
}

さらにマトリクスを取得する関数が書かれていますが、ビルトインパイプラインでシェーダを書いたことがある人であれば見慣れたマトリクス変数を返しているだけのシンプルな関数です。

それぞれは以下のように値を返しています。

// Transform to homogenous clip space
float4x4 GetWorldToHClipMatrix()
{
    return UNITY_MATRIX_VP;
}
// Return the PreTranslated ObjectToWorld Matrix (i.e matrix with _WorldSpaceCameraPos apply to it if we use camera relative rendering)
float4x4 GetObjectToWorldMatrix()
{
    return UNITY_MATRIX_M;
}

シンプルに、頂点に対して座標変換のマトリクスを掛けているだけですね。

まとめ

以上がSRP Batcherを有効にするURPにおけるシンプルなシェーダについてでした。
基本的な書き方や考え方はビルトインパイプラインと大きく変わるものではありません。

なにをどう書いたらいいかさえ分かっていれば、URP向けシェーダを書くのはそれほど大変ではないでしょう。
注意点としては、URPではHLSLを利用するという点です。

いちおうCgも使えるのですが、その場合はどうやら不要なシェーダなどをincludeしてしまうらしく、できれば避けたほうがよさそうです。

みなさんもぜひ良いURPライフを。

【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を経由しているのでそのあたりが気になるところです。(まだ計測はしていませんが・・・)

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