e.blog

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

Unityで簡易TexturePackerを実装してみた

はじめに

こちらの記事を参考にUnityで簡易的なTexturePackerを実装してみたのでそのまとめです。

blackpawn.com

ちなみにランダムに生成したものを配置したのがこれ↓

実装しようと思った理由は今実装中の機能に必要になったためです。
今、下の動画のようなスタンプ機能を作っているのですが、プレビュー用には1枚のテクスチャしか渡せないため複数設定されたテクスチャを自前でパッキングして利用したいと思ったのが理由です。

※ ただ、実際のスタンプ実装はまったく別の方法に切り替えたのでテクスチャパックは必要なくなりましたが・・w

今回実装したものは以下のGitHubにアップしてあります。

github.com

概要

今回参考にした記事はライトマップをパックするものを解説しているようです。
ライトマップは特に、ひとつのテクスチャに大量のライト情報をパックして利用するためこうした方法が利用される最適なケースでしょう。

上でも書いたように、今回の実装理由はライトマップと意図は同じで複数のテクスチャをひとつにまとめてシェーダに送りたい、というのが発端です。

実装のフロー

まずはざっくり全体像を概観してみます。
参考にした記事にはコンセプトの説明と図解、そして疑似コードが掲載されています。
今回の実装はその疑似コードを元に実装したものになります。


実装は二分木構造になっていて、最初はルートノードのみが存在します。

そしてテクスチャをパックするたびに、そのテクスチャがピッタリ収まるノードに分割していき、そこへ画像を設定していく、というイメージです。

もう少し具体的なフローにすると、

  1. とあるノードに対して、パックしようとしているテクスチャが収まるかチェックする(だいたいの場合はまったく収まらないか完全に収まるかの2択)
  2. 入る場合かつ完全フィットしていない場合はその領域を、対象画像が収まるサイズとそれ以外の領域に分ける(一度に縦横を分割するのではなく、そのテクスチャの長辺方向にだけ分割するのがポイント
  3. 上記で分割したノードに対して再びフィットするかをチェック(前段で長辺側を区切りに分割しているため、必ず短辺はノードにフィットする)
  4. (2)と同様のチェックを行うと、今度は必然的に短辺側に区切ることになる。(最終的にはそのテクスチャがぴったり収まるサイズに分割される)
  5. 対象画像が完全にフィットするようになったノードに対してImageIDを設定しリーフノード扱いにする
  6. 次のテクスチャを追加する
  7. ルートノードから子ノードを辿り空いているノードImageIDがないノード)を探す
  8. 以後、(1)〜(7)をテクスチャの数だけ繰り返す

図解すると以下のようになります。

f:id:edo_m18:20191222112136j:plain

見てもらうと分かるように、ひとつのノードに対して分割は「2回」発生します。
最初自分が勘違いしていた部分なのですが、テクスチャのサイズに同時に縦横を分割するのではなく、あくまで一度のチェックでは「長辺方向」にだけ分割します。

該当部分のコードを抜粋すると以下のようになります。

float dw = Rectangle.width - image.Width;
float dh = Rectangle.height - image.Height;

if (dw > dh)
{
    Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, image.Width, Rectangle.height);
    Child[1].Rectangle = new Rect(Rectangle.x + image.Width + 1, Rectangle.y, Rectangle.width - image.Width - 1, Rectangle.height);
}
else
{
    Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, Rectangle.width, image.Height);
    Child[1].Rectangle = new Rect(Rectangle.x, Rectangle.y + image.Height + 1, Rectangle.width, Rectangle.height - image.Height - 1);
}

ここでRectangleは確認しているノードの矩形を表していて、imageがパックしようとしている画像を表しています。

そしてそれぞれの幅と高さを引き、どちらが大きいかで分岐しています。

分岐内のコードはほぼ同じで、長辺方向に分割しています。

するとひとつのノードがふたつのノードに分割されます。
そして、プログラムのロジック的には再帰処理的に、分割された片方のノードに対してテクスチャの挿入処理を繰り返します。

すると、自動的に該当テクスチャがぴったりと収まるサイズに分割されたノードが出来上がります。

コードで言うと以下のところが、2回目のチェック時にはdw(あるいはdh)が0となり、前段で分割した方向とは異なる方向が分割方向として採用され、結果、対象テクスチャがぴったり収まるノードができあがる、というわけです。

float dw = Rectangle.width - image.Width;
float dh = Rectangle.height - image.Height;

if (dw > dh)
{
    // Check witch edge is short.
}

コード全容

実装は上記の図の通りにノードを分割していき、パックする位置が決定したらそのノードに対してImageIDを付与しリーフノードとする、という処理をテクスチャ分だけ繰り返すのみです。

あとはコードを見たほうが理解が早いと思うのでコード全体を載せておきます。

public class Node
{
    public Node[] Child = new Node[2];
    public Rect Rectangle;
    private int _imageID = -1;

    private bool _isLeafNode = true;

    private bool CheckFitInRect(IPackImage image)
    {
        bool isInRect = (image.Width <= Rectangle.width) &&
                        (image.Height <= Rectangle.height);
        return isInRect;
    }

    private bool CheckFitPerfectly(IPackImage image)
    {
        bool isSameBoth = (image.Width == Rectangle.width) &&
                            (image.Height == Rectangle.height);
        return isSameBoth;
    }

    public Node Insert(IPackImage image)
    {
        if (!_isLeafNode)
        {
            Node newNode = Child[0].Insert(image);
            if (newNode != null)
            {
                return newNode;
            }

            return Child[1].Insert(image);
        }
        else
        {
            if (_imageID != -1)
            {
                return null;
            }

            if (!CheckFitInRect(image))
            {
                return null;
            }

            if (CheckFitPerfectly(image))
            {
                return this;
            }

            _isLeafNode = false;

            Child[0] = new Node();
            Child[1] = new Node();

            float dw = Rectangle.width - image.Width;
            float dh = Rectangle.height - image.Height;

            if (dw > dh)
            {
                Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, image.Width, Rectangle.height);
                Child[1].Rectangle = new Rect(Rectangle.x + image.Width + 1, Rectangle.y, Rectangle.width - image.Width - 1, Rectangle.height);
            }
            else
            {
                Child[0].Rectangle = new Rect(Rectangle.x, Rectangle.y, Rectangle.width, image.Height);
                Child[1].Rectangle = new Rect(Rectangle.x, Rectangle.y + image.Height + 1, Rectangle.width, Rectangle.height - image.Height - 1);
            }

            return Child[0].Insert(image);
        }
    }

    public void SetImageID(int imageID)
    {
        _imageID = imageID;
        _isLeafNode = true;
    }

    public int GetImageID()
    {
        return _imageID;
    }

    public Node Find(int imageID)
    {
        if (imageID == _imageID)
        {
            return this;
        }

        if (_isLeafNode)
        {
            return null;
        }

        Node child = Child[0].Find(imageID);
        if (child != null)
        {
            return child;
        }

        child = Child[1].Find(imageID);

        return child;
    }
}

シェーダによる書き込みと読み込み

今回のPackerはシェーダによって書き込みを行っています。
また書き込んだだけでは使えないので、実際に利用する際の読み込み用のシェーダも必要となります。

次はそれらシェーダについて解説します。

シェーダによる書き込み

CPU側から適切にパラメータを設定したのち、シェーダで書き込みます。
C#側の処理は以下のようになっています。

private void Pack(IPackImage image, Rect rect)
{
    Vector4 scaleAndOffset = GetScaleAndOffset(rect);

    _material.SetVector("_ScaleAndOffset", scaleAndOffset);
    _material.SetTexture("_PackTex", image.Texture);

    Graphics.Blit(_current, _next, _material);

    SwapBuffer();
}

_ScaleAndOffsetがパック先テクスチャの「どの位置にどれくらいのサイズで」書き込むかのパラメータで_PackTexが書き込むテクスチャです。

さて、これを利用しているシェーダはどうなっているかというと、

fixed4 frag (v2f i) : SV_Target
{
    float2 puv = i.uv;
    puv -= _ScaleAndOffset.zw;
    puv *= _ScaleAndOffset.xy;

    fixed4 col = tex2D(_MainTex, i.uv);

    if (puv.x < 0.0 || puv.x > 1.0)
    {
        return col;
    }

    if (puv.y < 0.0 || puv.y > 1.0)
    {
        return col;
    }

    return tex2D(_PackTex, puv);
}

フラグメントシェーダによって渡されたUV値(つまりパック用テクスチャ全体のUV値)を、C#側から設定したスケールとオフセットを用いて変換し、それを利用して書き込みを行っています。

UV0より下、あるいは1より上の場合は書き込もうとしているテクスチャの範囲外なので現在のテクスチャの色(※)を返します。

※ ... シェーダによる書き込みは、一度にすべてのテクスチャをパックするのではなく、「ひとつずつ」パックを行っています。その際、ダブルバッファを利用して交互にRenderTexuterを交換しながら書き込んでいるため、上記UV値をはみ出した部分は以前に書き込まれたテクスチャの色がある可能性があるためこうしています。

シェーダによる読み込み

さて、上記まででパックが終わりました。あとはそれを利用して画面に表示する必要があります。
つまりパックされたテクスチャから、該当のテクスチャ部分を読み出す必要があります。

読み出し用のシェーダは以下のようになっています。

fixed4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
    uv /= _ScaleAndOffset.xy;
    uv += _ScaleAndOffset.zw;

    uv = clamp(uv, 0.0, 1.0);

    fixed4 col = tex2D(_MainTex, uv);
    return col;
}

書き込み用のシェーダと見比べてもらうと分かりますが、オフセット/スケールの計算が逆になっただけですね。

書き込み時は「オフセットさせて」から「スケールさせる(掛ける)」という手順でしたが、読み込み時は「スケールさせて(割って)」から「オフセットさせる」という手順で復元しています。

最後に

今回の実装では「回転」は考慮していません。
回転すればより良い位置があったとしても、それは考慮していない、ということです。

とはいえ冒頭に載せたようにそこまで隙間が目立つわけではないのでちょっとした利用くらいなら問題ないかなと思っています。

ちなみに読み込み/書き込みした動画がこちら↓

ちょっとした容量削減だったり、レンダリング負荷軽減目的なら意外と使えそうです。

World SpaceのCanvasにWorld SpaceからRaycastする

概要

ARやVRなどの開発を行っているとGUIも3D空間に配置する必要があります。
しかしUnityのuGUIは2Dで扱うことを想定しており、通常のGraphicRaycastはスクリーンスペースの位置から判定を行うものになっています。

つまり、3D空間に置かれた(World Spaceな)Canvasに対して、3D空間上にあるポインタ(例えば指やコントローラなど)からuGUIに対してRaycastしようとすると簡単には行きません。

そこで、ARやVRでも利用できるRaycastの仕組みを作らないとなりません。
以前、VRTKというフレームワークを参考に自分でEventSystemを拡張したのですが、その際に利用したWorld SpaceなCanvasへの3DオブジェクトからのRaycastをするために必要な部分について、(改めて必要になったので)メモとして残しておきたいと思います。

実際に動かしたやつはこんな感じです↓

処理フロー

まずはざっくり概観してみましょう。

  1. Canvasに配置されているGraphicすべてを判定対象にする
  2. Rayの方向とGraphicforward方向が同じかチェックする
  3. Rayの位置とGraphicの位置の距離を計算する
  4. Ray.GetPointを利用してワールドの位置を求める
  5. 求めたワールド位置をカメラのスクリーンスペース位置に変換する
  6. 変換した位置を元に、RectTransformがそれを含んでいるかチェックする
  7. 含んでいたらRaycast成功

コード

さて、全体の流れを説明したところでコードを見てみます。
コード自体はそこまで長くありません。

private void Raycast(Canvas canvas, Camera eventCamera, Ray ray)
{
    if (!canvas.enabled)
    {
        return;
    }

    if (!canvas.gameObject.activeInHierarchy)
    {
        return;
    }

    IList<Graphic> graphics = GraphicRegistry.GetGraphicsForCanvas(canvas);
    for (int i = 0; i < graphics.Count; i++)
    {
        Graphic graphic = graphics[i];

        if (graphic.depth == -1 || !graphic.raycastTarget)
        {
            continue;
        }

        Transform graphicTransform = graphic.transform;
        Vector3 graphicForward = graphicTransform.forward;

        float dir = Vector3.Dot(graphicForward, ray.direction);

        // Return immediately if direction is negative.
        if (dir <= 0)
        {
            continue;
        }

        float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin) / dir;

        Vector3 position = ray.GetPoint(distance);
        Vector2 pointerPosition = eventCamera.WorldToScreenPoint(position);

        // To continue if the graphic doesn't include the point.
        if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera))
        {
            continue;
        }

        // To continue if graphic raycast has failed.
        if (!graphic.Raycast(pointerPosition, eventCamera))
        {
            continue;
        }

        Debug.Log($"Raycast hit at {graphic.name}", graphic.gameObject);
    }
}

すべてのGraphicを評価する

IList<Graphic> graphics = GraphicRegistry.GetGraphicsForCanvas(canvas);によって対象CanvasにあるGraphicオブジェクトのリストを得ることが出来ます。

ワールド空間の位置からスクリーンスペースの位置を求める

ワールド空間からスクリーンスペースの位置を求める方法です。
今回は特定オブジェクトがポインタの役割を果たして、そのオブジェクトからのRayを使いたいわけなので、少しだけ計算が必要となります。

コード部分は以下。

Transform graphicTransform = graphic.transform;
Vector3 graphicForward = graphicTransform.forward;
float dir = Vector3.Dot(graphicForward, ray.direction);

// Return immediately if direction is negative.
if (dir <= 0) { continue; }

float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin) / dir;

まず、対象Graphicforwardray.direction内積を取りその値で分岐します。
これはuGUIのオブジェクトのforward方向はこちらを向いている面の反対側になります。
そのため、Rayの方向と同じ場合を向いている場合(つまり内積が0より上の場合)だけ処理すればいいことになります。

なので内積結果が0以下だった場合は無視しているわけです。

そして同じ方向を向いていた場合はレイの位置とレイがぶつかった位置の距離を計算します。
コードにすると以下の部分。(分かりやすいように改行を入れています)

float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin);
distance /= dir;

Graphicforward方向」と「Graphicオブジェクトの位置からレイの位置を引いたベクトル」の内積を計算し、dirで割っています。

本来はVector3.Distance(from, to);で求めてもいいのですが、距離計算は負荷が高めなのと、上記計算で内積と、すでに計算済みのdirとの除算のみで求まるためそちらを利用しています。(dirを求める必要があるため一石二鳥、というわけです)

なぜこれで距離が求まるのかと言うと、差分ベクトルとの内積によってGraphicforward方向、すなわちGraphicが存在する平面との最短距離が求まります。

そしてそれを実際にGraphicが存在する位置の長さとするためには三角関数を利用します。
つまり、半径 / cos(θ)が実際に求めたい距離です。

そして実はdir内積の結果はまさにこのcos(θ)の値となっているため、それで割ることで距離が求まっていた、というわけです。

図にすると以下です。

f:id:edo_m18:20191220104604j:plain

RectTransformUtilityを利用して当たり判定

そして最後に、取得した全Graphicオブジェクトに対して当たり判定をしてやればOKなわけです。
当たり判定自体は関数が用意されていてRectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera)を使います。

引数には対象となるGraphicRectTransform、スクリーンスペースのposition、そして判定対象となるCameraです。

対象オブジェクトにイベントを送る

今回は主にuGUIのオブジェクトをワールド空間から把握するためのもののメモなので、ちゃんとしたイベント周りの構築については以下の過去の記事を参考にしてみてください。

edom18.hateblo.jp

ただ、ざっくりでいいからイベントを送りたい、というケースもあるかと思います。
その場合は以下のように、Rayがヒットしたオブジェクトに対してイベントを送ってやればOKです。

// graphic object is detected by ray casting.
ExecuteEvents.Execute(graphic.gameObject, new BaseEventData(eventSystem), ExecuteEvents.submitHandler);
````

Compute ShaderとGraphics.DrawMeshInstancedIndirectを使ったレンダリングを理解する

概要

f:id:edo_m18:20191124215851p:plain

今回はGraphics.DrawMeshInstancedIndirectメソッドを使ってGPUパーティクルをレンダリングする方法をまとめます。
レンダリングに利用するパーティクルの位置計算はコンピュートシェーダで行います。

コンピュートシェーダについては過去に2つ記事を書いているので以下を参照ください。

edom18.hateblo.jp

edom18.hateblo.jp

ドキュメントはこちら。今回はこのドキュメントに沿って実装しつつ、できるだけミニマムに実装しました。

docs.unity3d.com

実際に動作するサンプルはGitHubにアップしてあります。

github.com

実際に動作している動画はこちら↓

ComputeShaderを準備する

まずはComputeShaderを用意します。
ComputeShaderを利用してGPUインスタンシングされたパーティクルの位置計算を行います。
今回実装するのはシンプルに、ランダムな位置から徐々に元の位置に戻るアニメーションです。

コード

ComputeShaderのコードを掲載します。

#pragma kernel ParticleMain

struct Particle
{
    float3 basePosition;
    float3 position;
    float4 color;
    float scale;
};

RWStructuredBuffer<Particle> _ParticleBuffer;

float _DeltaTime;

[numthreads(8, 1, 1)]
void ParticleMain(uint3 id : SV_DispatchThreadID)
{
    const int index = id.x;

    Particle p = _ParticleBuffer[index];
    p.position += (p.basePosition - p.position) * _DeltaTime;

    _ParticleBuffer[index] = p;
}

コンピュートシェーダ自体の説明は割愛します。詳細については上で紹介した記事を参照ください。
ここではGraphics.DrawMeshInstancedIndirectを利用する上で重要な点に絞って解説します。

システム全体で利用する構造体を定義する

今回はGPUパーティクルなので、パーティクルシステムで利用する構造体を定義します。
これはComputeShader内だけでなく、C#側のコードと、そしてGPUパーティクルをレンダリングするシェーダ側でも定義する必要があります。

Particle構造体は以下。

struct Particle
{
    float3 basePosition;
    float3 position;
    float4 color;
    float scale;
};

注意点は構造体のレイアウトをすべて同じにすることです。
構造体は宣言された順番にメモリにレイアウトされます。

この順番が異なっていると各プログラム間で整合性が取れなくなってしまうので気をつけてください。

処理を少しだけ説明しておくとRWStructuredBuffer<Particle>型のバッファから、カーネルParticleMain)の引数に渡されるスレッドID(SV_DispatchThreadIDセマンティクス)を使って計算対象となるParticle構造体を取り出します。

そして現在位置(Particle.position)から元の位置(Particle.basePosition)へ徐々に近づけていきます。

C#コード側でデータを準備し計算を実行する

次にC#側のコードを見てみましょう。
C#側で行うのは各データ(バッファ)の準備と計算の実行開始(Dispatch)、そしてコンピュートシェーダで計算されるバッファをシェーダ(マテリアル)に対して紐付けることです。

順に見ていきましょう。

コンピュートシェーダで利用するものと同じレイアウトの構造体を定義する

C#側でもコンピュートシェーダで利用するのと同じレイアウトの構造体を定義します。
構造体の定義は以下。

private struct Particle
{
    public Vector3 basePosition;
    public Vector3 position;
    public Vector4 color;
    public float scale;
}

若干 型が違っていますが配置の順番が同じ点に注目してください。(floatN型はそれぞれVectorN型になります)

各データ用変数を定義

C#側のタスクとして、データの準備と毎フレームごとの更新処理リクエストがあります。

ということでまずはデータの宣言について見てみましょう。

[SerializeField] private int _count = 10000;
[SerializeField] private ComputeShader _computeShader = null;

private ComputeBuffer _particleBuffer = null;
private ComputeBuffer _argBuffer = null;
private uint[] _args = new uint[5] { 0, 0, 0, 0, 0, };

コンピュートシェーダで利用する変数についてのみ抜粋しました。

これをセットアップしていきます。

データのセットアップ

データのセットアップをしているところを抜粋します。

List<Vector3> vertices = new List<Vector3>();
_targetMeshFilter.mesh.GetVertices(vertices);

Particle[] particles = new Particle[_count];

for (int i = 0; i < _count; i++)
{
    particles[i] = new Particle
    {
        basePosition = vertices[i % vertices.Count],
        position = vertices[i % vertices.Count] + Random.insideUnitSphere * 10f,
        color = _color,
        scale = Random.Range(0.01f, 0.02f),
    };
}

_particleBuffer = new ComputeBuffer(_count, Marshal.SizeOf(typeof(Particle)));
_particleBuffer.SetData(particles);

_computeShader.SetBuffer(_kernelId, "_ParticleBuffer", _particleBuffer);

今回のサンプルは指定したオブジェクト(メッシュ)の頂点位置からランダムに離れた位置にパーティクルを初期配置し、あとは元の頂点位置に徐々に戻っていくというものです。
そのためセットアップはターゲットとなるメッシュの各頂点位置を取得し、それを元に初期状態をバッファに設定しています。

バッファにセットする用の配列にデータを詰め込みそれを元にComputeBufferを生成、それをコンピュートシェーダにセットします。

Indirect(間接実行)用のバッファを用意する

ちょっとまだしっかりとは把握しきれていないのですが、このバッファはインスタンスを管理するために用いられるもののようです。
具体的にはインスタンス数や頂点数を渡し、適切な数頂点シェーダなどが起動するようにするもののようです。

ちなみに、凹みさんの記事から引用させてもらうと以下のように説明されていました。(Draw Procedural Indirectの箇所を参照)

ComputeBuffer.CopyCount() では追加した要素数を調べることが出来ます。この際、第 2 引数に渡している ComputeBufferType.IndirectArguments フォーマットのバッファは int 4 つ分のバッファです。GetData() をして一旦 CPU 側に値を持ってきてみると、何個の要素数が追加されたか確認できます。

tips.hecomi.com

そしてコード例として載っているのが以下。

var args = new int[4];
cbDrawArgs.GetData(args);
Debug.LogFormat("vert: {0}, {1}, {2}, {3}", args[0], args[1], args[2], args[3]);
// --> 939, 1, 0, 0

数値が格納されているのが第1要素と第2要素のみです。
さて、それを踏まえた上で今回のセットアップコードを見ると以下のようになっています。

int subMeshIndex = 0;

// Indirect args.
_args[0] = _targetMeshFilter.mesh.GetIndexCount(subMeshIndex);
_args[1] = (uint)_count;
_args[2] = _targetMeshFilter.mesh.GetIndexStart(subMeshIndex);
_args[3] = _targetMeshFilter.mesh.GetBaseVertex(subMeshIndex);

_argBuffer = new ComputeBuffer(1, sizeof(uint) * _args.Length, ComputeBufferType.IndirectArguments);
_argBuffer.SetData(_args);

第1要素がターゲットとなるメッシュのインデックス数、そして第2要素がパーティクルの数です。
ドキュメントには第3、第4要素も値が設定されているのですが試しにこれを0にしても正常に動きました。

なので3、4要素目はなにを意味しているのかちょっとまだ分かっていません。

分かっているのは、第1要素はインスタンスとしてレンダリングされる対象の頂点数、そして第2要素がインスタンスの数になることです。

このバッファを、DrawMeshInstancedIndirectを実行する際に引数に渡してやることで無事、レンダリングが行われます。

レンダリング用シェーダを作成する

データの準備、計算、更新処理が完成したら最後はパーティクルをレンダリングするシェーダを準備します。
それほど長くないのでまずはコード全体を掲載します。

Shader "Unlit/Particle"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct Particle
            {
                float3 basePosition;
                float3 position;
                float4 color;
                float scale;
            };

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 color : COLOR;
            };

            StructuredBuffer<Particle> _ParticleBuffer;

            v2f vert (appdata v, uint instanceId : SV_InstanceID)
            {
                Particle p = _ParticleBuffer[instanceId];

                v2f o;

                float3 pos = (v.vertex.xyz * p.scale) + p.position;
                o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
                o.color = p.color;

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return i.color;
            }

            ENDCG
        }
    }
}

まず見てもらいたいのが、コンピュートシェーダとC#側で定義してきたParticle型の構造体があることです。
こちらも同様にレイアウトが同じになっています。

そして普段シェーダを書いているとあまり見慣れないStructuredBuffer<>という型が宣言されています。
これがまさにコンピュートシェーダで計算されたバッファが受け渡される変数です。

頂点位置を計算する

通常、シェーダはとあるオブジェクト(メッシュ)をレンダリングするために用いられます。
つまりレンダリングする対象はひとつのみです。

しかし今回は対象のメッシュはパーティクルひとつひとつを対象とし、それぞれがインスタンシングされるため複数オブジェクトを描くようにしないとなりません。

頂点シェーダのコードを抜粋してみましょう。

v2f vert (appdata v, uint instanceId : SV_InstanceID)
{
    Particle p = _ParticleBuffer[instanceId];

    v2f o;

    float3 pos = (v.vertex.xyz * p.scale) + p.position;
    o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
    o.color = p.color;

    return o;
}

普段見慣れないSV_InstanceIDというセマンティクスがついた引数があります。
これはGPUインスタンシングされた情報に対して何番目のオブジェクトかを表すIDとなります。
このIDを利用してコンピュートシェーダで計算された情報にアクセスします。
つまり以下の部分です。

Particle p = _ParticleBuffer[instanceId];

こうすることで起動された頂点シェーダが今、どのパーティクルを処理しているかが分かるわけです。

頂点の座標を変換する

次に対象パーティクルの頂点の座標変換処理を見てみましょう。

float3 pos = (v.vertex.xyz * p.scale) + p.position;
o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));

v.vertex.xyzはパーティクルメッシュの頂点情報です。(今回はCubeを利用しています)
そしてその頂点をまずスケーリングし、そのあとで、コンピュートシェーダによって計算された位置を足し込んでパーティクルの位置としています。

Cubeの各頂点が一律同じように移動されるので、結果的にパーティクルとしてのCubeが移動するというわけですね。
そして実質、これがモデル行列を掛けた状態、つまりワールド座標空間での位置になります。

なのであとはビュー行列とプロジェクション行列を掛けてあげれば無事、クリッピング座標系に変換されるというわけです。

o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));

として頂点シェーダの出力にしているわけですね。
これで無事、パーティクルが画面に表示されるようになります。

まとめ

シンプルな実装ならばコード量はさほど多くありません。
たくさんのパーティクルを出して絵を盛るもよし、複雑な処理をインスタンシングの力で打破するもよし。
使い方が分かれば色々と応用が効きそうです。

もしもっと複雑な処理をしたい場合は、以下のカールノイズの記事を参考に実装してみるといいかもしれません。

edom18.hateblo.jp

edom18.hateblo.jp

Unityで簡易的なドローイングツールを作ってみたので実装についてまとめ

概要

Unityで簡易的なドローイング機能がプロジェクトで必要になったので作ってみたもののまとめです。
実装はシェーダを利用して描いていて、ブラシと色とサイズを変更できるようになっています。

実際の動きはこんな感じ↓

ここで解説している機能のアセットはアセットストアで公開されているのでよかったら購入してください。

assetstore.unity.com

実装方針

実装方針は以下です。

  1. 描画対象となるImage要素上の位置を算出する
  2. 上記位置をImage要素内のUV値に変換する
  3. 該当位置に対して、設定されたブラシを描画する
  4. ブラシを描画する際は、さらにブラシ用のUV値を求める
  5. 描画ごとにバッファを差し替えて連続して描画する(ダブルバッファ)

という流れで実装しています。
実際のアセットではもう少し細かい調整を行っていますが、メインとなる処理は上記の通りです。

Image要素上の位置を算出する

Image(厳密にはRectTransform)上の位置を計算するには

public static bool ScreenPointToLocalPointInRectangle(RectTransform rect, Vector2 screenPoint, Camera cam, out Vector2 localPoint);

を利用します。

ドキュメント↓

docs.unity3d.com

実装は以下のような感じです。

private bool TryConvertToLocalPosition(Vector3 screenPos, out Vector2 pos)
{
    Camera camera = null;
    if (_drawImage.canvas.renderMode != RenderMode.ScreenSpaceOverlay)
    {
        camera = _camera;
    }

    return RectTransformUtility.ScreenPointToLocalPointInRectangle(_rectTrans, screenPos, camera, out pos);
}

RenderModeの判定を行っていますが、通常のモード(ScreenSpaceOverlay)の場合はCameraは関係ないのでnullを渡す必要がある点に注意してください。

この関数は、対象となる位置ベクトルがRectTrasnform内にある場合はtrueを返します。そして引数の最後で、ローカルの位置を格納して戻してくれます。
この位置ベクトルを次のUV値計算に利用します。

ちなみに、位置は関係なく、RectTrasnform内に入っているか、だけを判定する関数もあるので、入っているか否かだけを判断する場合はこちらの関数を利用するといいでしょう。

public static bool RectangleContainsScreenPoint(RectTransform rect, Vector2 screenPoint);
    // or
public static bool ScreenPointToLocalPointInRectangle(RectTransform rect, Vector2 screenPoint, Camera cam, out Vector2 localPoint);

要素上の位置をUV値に変換する

要素上の位置が求まったら、それをUV値に変換します。
といっても大した計算はしません。対象の要素(Image)のサイズでそれぞれ正規化してやればOKです。

// WidthとHeightはRectTransformのサイズ
// private float Width => _rectTrans.rect.width;
// private float Height => _rectTrans.rect.height;

private Vector4 NormalizePosition(Vector2 pos)
{
    float x = (pos.x + Width * 0.5f) / Width;
    float y = (pos.y + Height * 0.5f) / Height;
    return new Vector4(x, y, 0, 0);
}

注意点としては、RectTransform内の位置は中心を0として、プラスマイナスの値で返ってくるため、0 ~ 1の間になるように調整する必要があります。

ブラシのUVを算出する

位置が求められたので、次はブラシを描く位置とサイズを調整します。

と言ってもやっていることはむずかしくありません。
ひとつ問題なのは、描こうとしている対象のRenderTextureのUV値をそのまま利用することができません。
そのまま利用してしまうと、ただ単に、ブラシテクスチャをRenderTextureの範囲いっぱいに描いてしまうことになるからです。

なのでブラシ用に新しくUV値を計算する必要があるわけです。
といっても計算自体はそこまでむずかしくありません。

図にすると以下のようなイメージです。

f:id:edo_m18:20191110144113p:plain

今回の実装では以下のようにしました。

// 描きたい位置にまずオフセットする
float2 buv = i.uv - _Pos.xy;

// 次に、ブラシサイズになるようにUV空間をスケールする
buv *= _BrashSize;

// RenderTextureのアスペクト比を掛けて比率を調整する
buv.x *= _Aspect;

// 最後に、ブラシが位置の中央に描かれるようにオフセットする
buv += float2(0.5, 0.5);

オフセットのプラスマイナスは注意が必要です。図にすると以下のイメージ。

f:id:edo_m18:20191110144816p:plain

最後の計算が0.5になっているのは、この計算時点でブラシ用UV座標(0 ~ 1の範囲)に変換されているため、中心位置に変換するためには1.0の半分である0.5を足すだけで大丈夫です。

なお、ブラシUV座標に変換するためにブラシサイズを掛けていますが、ブラシサイズはC#側から計算して送っています。
具体的には以下のようにして計算しています。

private float NormalizeBrushSize(float brushSize)
{
    return Height / brushSize;
}

HeightRectTransformの高さですね。前の計算でアスペクト比があっているのでHeightを使っています。
そしてそれをブラシサイズで割った値を利用しています。これは、ブラシサイズになるように空間を「縮小」することで実現しています。

コードを見てみると計算自体は複雑ではありませんね。
ただ、UV座標を色々いじっていると頭の中が混乱することが多いです。
例えば、ブラシサイズを「小さく」しようとする場合は、スケールを「掛ける」必要があります。

uv *= 2.0; とすると「半分」のサイズになる。

よくよく考えれば当たり前なのですが、計算としては逆なのでたまに勘違いして計算を逆にしてしまうことがよくあります。

UVの計算については以前のこの記事を書いたときに、色々試していてだいぶ慣れてきました。

edom18.hateblo.jp

色を混ぜる

最後に、描こうとしている対象の色とブラシの色を混ぜ合わせて最終結果を作ります。
最初はたんに掛けたり足したりしていたのですが、それだけだと望んだ結果になりません。

最終的には、いわゆるアルファブレンディングと同じ方法でブレンドすることで解決しました。
コードは以下のような感じ。

fixed4 brash = tex2D(_BrashTex, buv);

// ブラシの黒いところに色をつけたいので反転させる
brash.rgb = 1.0 - brash.rgb;

// Blend SrcAlpha OneMinusSrcAlphaな感じでブレンドした色を返す
return lerp(col, (brash * _Color), brash.a);

このアセットに興味が出たらぜひ購入をお願いします!

assetstore.unity.com

Unityが起動しなくなってあれこれしたことを殴り書きしておく

概要

ある日突然、Unityが死んだ。

シーン読み込み時にフリーズして再起動したら、Unity自体が起動しなくなるという自体に遭遇。
結果的に4時間溶かしてなんとか解決した・・・。

ただ、正直謎が多すぎてこれで直るか分からないのだけど、同じ悩みを持っている人を助けられるかもしれないのでメモを残しておきます。

やったこと

ちなみにそのときに一番調べていた単語が「Native extension for iOS target not found」です。
これは、Unity Editorのログファイルに残されていた手がかりです。
(ちなみにログファイルはここ→ ~/Library/Logs/Unity/Editor.log

そのときのログファイル全文を残しておきます。

Initiating legacy licensing module
[Package Manager] Server::Start -- Port 61119 was selected
Launching external process: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/Server/UnityPackageManager

 COMMAND LINE ARGUMENTS:
/Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/MacOS/Unity
-projectpath
/Users/kazuyahiruma/MyDesktop/UnityProjects/hoge
-useHub
-hubIPC
-cloudEnvironment
production
-buildTarget
iOS
-hubSessionId
529ac9d0-f0ba-11e9-ba43-8bfa11da91e7

LICENSE SYSTEM [20191017 17:48:0] Next license update check is after 2019-10-18T07:36:42

Successfully changed project path to: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge
/Users/kazuyahiruma/MyDesktop/UnityProjects/hoge
Using Asset Import Pipeline V1.
Loading GUID <-> Path mappings...0.000048 seconds
Loading Asset Database...0.122003 seconds
AssetDatabase consistency checks...0.722986 seconds
[Package Manager] Done resolving packages in 1.14s seconds
[Package Manager] 
Registered 40 packages:
  Packages from [https://packages.unity.com]:
    com.unity.collab-proxy@1.2.16 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.collab-proxy@1.2.16)
    com.unity.ext.nunit@1.0.0 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.ext.nunit@1.0.0)
    com.unity.ide.rider@1.1.0 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.ide.rider@1.1.0)
    com.unity.ide.vscode@1.1.0 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.ide.vscode@1.1.0)
    com.unity.test-framework@1.0.13 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.test-framework@1.0.13)
    com.unity.textmeshpro@2.0.1 (location: /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Library/PackageCache/com.unity.textmeshpro@2.0.1)
  Built-in packages:
    com.unity.package-manager-ui@2.2.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.package-manager-ui)
    com.unity.timeline@1.1.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.timeline)
    com.unity.ugui@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.ugui)
    com.unity.modules.ai@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.ai)
    com.unity.modules.androidjni@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.androidjni)
    com.unity.modules.animation@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.animation)
    com.unity.modules.assetbundle@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.assetbundle)
    com.unity.modules.audio@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.audio)
    com.unity.modules.cloth@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.cloth)
    com.unity.modules.director@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.director)
    com.unity.modules.imageconversion@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.imageconversion)
    com.unity.modules.imgui@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.imgui)
    com.unity.modules.jsonserialize@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.jsonserialize)
    com.unity.modules.particlesystem@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.particlesystem)
    com.unity.modules.physics@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.physics)
    com.unity.modules.physics2d@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.physics2d)
    com.unity.modules.screencapture@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.screencapture)
    com.unity.modules.terrain@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.terrain)
    com.unity.modules.terrainphysics@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.terrainphysics)
    com.unity.modules.tilemap@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.tilemap)
    com.unity.modules.ui@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.ui)
    com.unity.modules.uielements@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.uielements)
    com.unity.modules.umbra@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.umbra)
    com.unity.modules.unityanalytics@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unityanalytics)
    com.unity.modules.unitywebrequest@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unitywebrequest)
    com.unity.modules.unitywebrequestassetbundle@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unitywebrequestassetbundle)
    com.unity.modules.unitywebrequestaudio@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unitywebrequestaudio)
    com.unity.modules.unitywebrequesttexture@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unitywebrequesttexture)
    com.unity.modules.unitywebrequestwww@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.unitywebrequestwww)
    com.unity.modules.vehicles@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.vehicles)
    com.unity.modules.video@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.video)
    com.unity.modules.vr@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.vr)
    com.unity.modules.wind@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.wind)
    com.unity.modules.xr@1.0.0 (location: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/PackageManager/BuiltInPackages/com.unity.modules.xr)

[XR] No new subsystems found in resolved package list.
[Package Manager] Done registering packages in 0.05s seconds
Targeting platform: iOS
Refreshing native plugins compatible for Editor in 206.28 ms, found 6 plugins.
Preloading 0 native plugins for Editor in 0.00 ms.
IsTimeToCheckForNewEditor: Update time 1571299758 current 1571302083
Initialize engine version: 2019.2.3f1 (8e55c27a4621)
[XR] Discovering subsystems at path /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Resources/UnitySubsystems
[XR] Discovering subsystems at path /Users/kazuyahiruma/MyDesktop/UnityProjects/hoge/Assets
GfxDevice: creating device client; threaded=1
Initializing Metal device caps: Intel(R) Iris(TM) Plus Graphics 640
[EnlightenBakeManager] m_Clear = false;
Initialize mono
Mono path[0] = '/Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/Managed'
Mono path[1] = '/Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/MonoBleedingEdge/lib/mono/unityjit'
Mono config path = '/Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/MonoBleedingEdge/etc'
Using monoOptions --debugger-agent=transport=dt_socket,embedding=1,server=y,suspend=n,address=127.0.0.1:56155
Begin MonoManager ReloadAssembly
Registering precompiled unity dll's ...
Register platform support module: /Applications/Unity/Hub/Editor/2019.2.3f1/Unity.app/Contents/PlaybackEngines/MacStandaloneSupport/UnityEditor.OSXStandalone.Extensions.dll
Register platform support module: /Applications/Unity/Hub/Editor/2019.2.3f1/PlaybackEngines/iOSSupport/UnityEditor.iOS.Extensions.dll
Registered in 0.002006 seconds.
[usbmuxd] Start listen thread
[usbmuxd] Listen thread started
[usbmuxd] Send listen message
Native extension for iOS target not found

問題の一文はログの最後に出てくるやつですね。

どう解決したか

さて、どう解決したかですが、結論から書くと、問題がありそうなファイルをRenameしてみた、です。

問題のファイル(っぽいもの)はこちら↓

/Applications/Unity/Hub/Editor/2019.2.3f1/PlaybackEngines/iOSSupport/UnityEditor.iOS.Extensions.dll

iOS target not found と書いてあったのでこれかなと。
でやったことはこれをRenameしました。(要はこれを読み込ませないようにしてみたということ)

これをスキップさせたらどういうエラーが出るのかなーと思って。
が、結果は意図に反してなぜかUnity起動するという謎。ファイルあるときはnot foundなのに、ファイル自体Renameしたらなんで起動するんだ・・。

そしてさらに謎なのが、これで無事起動したあと、変更したファイル名を元に戻したら普通に起動するという。

とはいえ、もしこのあたりの問題で悩んでいる人がいたら上記を試してみてください。
ただ、あまりにも謎すぎるので直る保証は一切ないのであしからず・・・。

その他メモ

ちなみに、これも功を奏したのか分かりませんが、「Unityゲーム開発者ギルド」というSlackのコミュニティに質問したところ以下のような対応も教えてもらいました。
上記の方法でもダメな場合はこちらも試してみるといいかもしれません。

  • Unityを起動している場合は終了し、アンインストール
  • ~/Library/Unity/ を削除
  • /Library/Application Support/Unity/ にある .ulf 拡張子のファイルを削除
  • PCを再起動
  • Unityを再インストール。インストーラーが壊れている可能性があるため、インストーラーも再度ダウンロードする

ちなみに再インストールでもプチハマり。なんと、この前アップデートがあったばかりのMacOS Catalina の場合、普通の手順でインストーラからインストールしようとするとインストールできない旨のアラートが。

ただ、こちらの記事を見るとUnity Hubを経由してインストールすると行けるよ、とのこと。
DownloadページのUnity Hubで開くボタンからやると無事にインストールできました。

ネイティブテクスチャ経由で画面キャプチャ(RenderTexture)を保存する

概要

今回はiOSのネイティブプラグインを利用してRenderTextureをそのままファイル保存するプラグインを作ったので、作る過程で得られた知見やハマりどころなどをメモしていきたいと思います。

RenderTextureにはTexture2Dが持っているEncodeToPNGEncodeToJPGメソッドがなく、さらにそもそもEncodeToPNGなどは非常に重い処理となっています。

なのでRenderTextureをそのままネイティブ側に渡してそれが保存できないかな、と思ったのが実装するに至った経緯です。

実際に実行したときの動画↓

今回の実装にあたり、ふじきさんには多大なご助力をいただきました。本当にありがとうございます。

ふじきさんが作られた動画キャプチャツールの投稿を見たのがきっかけで質問させていただき、色々ご教授いただきました。
そのツールはこちら↓

フロー

まずは全体の流れを把握するためにフローを概観してみます。

  1. RenderTextureを用意
  2. CommandBufferを利用して画面をキャプチャ
  3. Texture2DReadPixelsを利用してRenderTextureピクセル情報を読み出す(*1)
  4. RenderTextureGetNativeTexturePtrを使ってネイティブテクスチャのポインタを取得
  5. (4)のポインタをネイティブプラグインへ送信
  6. (5)のテクスチャを適切な形に変換する
  7. (6)のデータをUIImageにして保存

*1 ... ReadPixelsを行わないと(おそらく)GPUにデータがすぐにアップロードされず、画像保存に失敗します。もしかしたらGL.IssuePluginEventを利用するとうまくいくかもしれません。 色々調査した結果、1フレーム遅延させることでReadPixelsを使わなくても正常に保存することができました。

以上が大まかな処理の流れとなります。

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

github.com

RenderTextureを用意

ここはむずかしいところはありません。
ファイルとして用意してもいいですし、ランタイムで用意してもよいです。
今回はランタイムで以下のように、Start時に用意するようにしています。

_buffer = new RenderTexture(Screen.width, Screen.height, 0);
_buffer.Create();

CommandBufferを用意する

コマンドバッファは画面をキャプチャする用途で利用しています。
セットアップは以下のように行っています。

_commandBuffer = new CommandBuffer();
_commandBuffer.name = "CaptureScreen";

_buffer = new RenderTexture(Screen.width, Screen.height, 0);
_buffer.Create();

_commandBuffer.Blit(BuiltinRenderTextureType.CurrentActive, _buffer);

// スクショを撮るタイミングでカメラにコマンドバッファをアタッチ
Camera.main.AddCommandBuffer(CameraEvent.BeforeImageEffects, _commandBuffer);

コマンドバッファはシンプルに、現在のアクティブなレンダーバッファの内容をレンダーテクスチャにコピーしているだけです。
コマンドバッファをアタッチするタイミングはスクショを撮るタイミングです(スクショ撮影用メソッドを呼ぶタイミング)。

CommandBufferについては凹みさんがこちらの記事でとても詳しく解説されています。

tips.hecomi.com

ReadPixelsでピクセル情報を読み出す(※ 必要ありませんでした)

キャプチャを行ったらTexture2DReadPixelsピクセル情報を読み出します。
保存だけを行いたい場合はこれは不要な処理となりますが、GPUへのデータアップロードの関連なのか、これを行わないとネイティブ側の保存時に空白の画像が保存されてしまい、うまくいきませんでした。

フローのところでも書きましたが、もしかしたらGL.IssuePluginEventを利用して読み出すことでうまく動くかもしれません。(これについては後日調査します)

CommandBufferでキャプチャ後、その後すぐに保存するのではなく、1フレーム遅延させることで正常に保存できることを確認しました。

コード的には以下のようにしています。(ReadPixelsしていたコードを変更しています)

    private IEnumerator SaveTexture()
    {
        yield return _waitForEndOfFrame;

        Debug.Log("Save texture to the file.");

        Camera.main.RemoveCommandBuffer(CameraEvent.BeforeImageEffects, _commandBuffer);

        _image.texture = _buffer;

        // To save the RenderTexture as file needs to wait one frame.
        yield return _waitForEndOfFrame;

        Debug.Log("Will show the texture.");

        _SaveTextureImpl(_buffer.GetNativeTexturePtr(), gameObject.name, nameof(CallbackFromSaver));
    }

RenderTextureのGetNativeTexturePtrを使ってネイティブテクスチャのポインタを取得

ここがひとつめの重要な点です。
UnityのTexture2DRenderTextureにはGetNativeTexturePtrというメソッドがあり、ネイティブ側のテクスチャのポインタを取得する方法があります。
今回のプラグインではネイティブ側で、この生成済みのテクスチャのポインタを利用して処理を行います。

取得して送信している箇所を抜粋すると以下のようになります。

// 第2、第3引数はネイティブ側からコールバックを受け取るためのもの
_SaveTextureImpl(_buffer.GetNativeTexturePtr(), gameObject.name, nameof(CallbackFromSaver));

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

docs.unity3d.com

ドキュメントから抜粋すると以下のように書かれています。

On Direct3D-like devices this returns a pointer to the base texture type (IDirect3DBaseTexture9 on D3D9, ID3D11Resource on D3D11, ID3D12Resource on D3D12). On OpenGL-like devices the GL texture "name" is returned; cast the pointer to integer type to get it. On Metal, the id pointer is returned. On platforms that do not support native code plugins, this function always returns NULL.

要するにこれはプラットフォームによって返ってくる意味が違っているということです。(プラットフォームごとに適切なテクスチャへのポインタが返ってくる)
そして今回はiOS向けの話なのでid<MTLTexture>へのポインタが返ってくることが分かります。

On Metal, the id pointer is returned.

ネイティブプラグインへテクスチャのポインタを送る

そしてこちらが重要な点のふたつめです。
前段で取得したネイティブテクスチャのポインタをネイティブプラグイン側へ送り受け取る必要があるわけですが、適切にキャストして利用する必要があります。

これを間違えるとクラッシュしたり、ということがあるので気をつけてください。
ポインタを取得してキャストするコードは以下のようになります。

extern "C" void _SaveTextureImpl(unsigned char* mtlTexture, const char* objectName, const char* methodName)
{
    id<MTLTexture> tex = (__bridge id<MTLTexture>)(void*)mtlTexture;

    // 後略
}

まず、テクスチャのポインタはunsigned char*型で受け取ります。
そしてそれを(__bridge id<MTLTexture>)(void*)mtlTexture;のように、いったんvoid*型を経由してから最後にid<MTLTexture>型にキャストします。

加えて注意点として、C側からObjective-C側へのキャストには(__bridge)を利用してキャストを行う必要があります。

このブリッジにはいくつか種類があり、ARC管理下にいれるか、など状況に応じて使い分ける必要があります。

__bridgeを利用したキャストについては以下の記事を参考にしてみてください。

balunsoftware.jp

また今回はUnity側で生成したテクスチャなのでGC対象にもなっています。
そのため、ネイティブ側で管理対象にはせず、そのままキャストするだけに留めます。

これを、ARC管理対象などにしてしまうとBAD ACCESSでアプリがクラッシュするので注意が必要です。
(ここらへんまだ詳しくないのであれですが、万全を期すなら、Unity側でGC対象のポインタをロック(GCHandle.Alloc)してメモリ位置が変更されないようにするなどのケアは必要かもしれません。が、今回はとにかく保存するところまでを書くのでこのあたりには触れません)

テクスチャを適切な形に変換する

テクスチャのポインタを受け取り、テクスチャの情報にアクセスすることができるようになりましたが、このままだと各ピクセルのデータの並びが異なっているため色味が反転したような絵になって保存されてしまいます。

これに対して、適切に対処してくれるコードを公開してくれていた記事があったのでこちらを参考にさせていただきました。

qiita.com

具体的に何をしているかというと、取得したMTLTextureはRGBAではなくBGRAの並びになっているので、それをRGBAな形に並び替える処理をしています。


上下の反転について

また参考にした記事では、環境によっては上下が反転してしまうため、それを補う処理も同時に施されています。

該当のコードは以下のようになっています。

// flipping image vertically
let flippedBytes = bgraBytes // share the buffer
var flippedBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: flippedBytes),
            height: vImagePixelCount(self.height), width: vImagePixelCount(self.width), rowBytes: rowBytes)
vImageVerticalReflect_ARGB8888(&rgbaBuffer, &flippedBuffer, 0)

ただ今回のサンプルでは上下の反転は必要なかったのでコメントアウトしてあります。
上下の反転があったら上のコードを試してみてください。


この工程を経ることで無事、望んだ形のデータが手に入ります。

上の記事ではMTLTextureの拡張として書かれているのでこれを少しだけ改変してコンバータクラスとして実装しました。

実際に利用しているコードは以下です。

//
//    MTLTexture+Z.swift
//    ZKit
//
//    The MIT License (MIT)
//
//    Copyright (c) 2016 Electricwoods LLC, Kaz Yoshikawa.
//
//    Permission is hereby granted, free of charge, to any person obtaining a copy
//    of this software and associated documentation files (the "Software"), to deal
//    in the Software without restriction, including without limitation the rights
//    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//    copies of the Software, and to permit persons to whom the Software is
//    furnished to do so, subject to the following conditions:
//
//    The above copyright notice and this permission notice shall be included in
//    all copies or substantial portions of the Software.
//
//    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
//    WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//    THE SOFTWARE.
//

import Foundation
import CoreGraphics
import MetalKit
import GLKit
import Accelerate

class MTLTextureConverter : NSObject {
    
    @objc static func convert(texture: MTLTexture) -> UIImage?
    {
        
        assert(texture.pixelFormat == .bgra8Unorm)
        
        // read texture as byte array
        let rowBytes = texture.width * 4
        let length = rowBytes * texture.height
        let bgraBytes = [UInt8](repeating: 0, count: length)
        let region = MTLRegionMake2D(0, 0, texture.width, texture.height)
        texture.getBytes(UnsafeMutableRawPointer(mutating: bgraBytes), bytesPerRow: rowBytes, from: region, mipmapLevel: 0)
        
        // use Accelerate framework to convert from BGRA to RGBA
        var bgraBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: bgraBytes),
                                       height: vImagePixelCount(texture.height), width: vImagePixelCount(texture.width), rowBytes: rowBytes)
        
        let rgbaBytes = [UInt8](repeating: 0, count: length)
        var rgbaBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: rgbaBytes),
                                       height: vImagePixelCount(texture.height), width: vImagePixelCount(texture.width), rowBytes: rowBytes)
        let map: [UInt8] = [2, 1, 0, 3]
        vImagePermuteChannels_ARGB8888(&bgraBuffer, &rgbaBuffer, map, 0)
        
        // flipping image virtically
        // let flippedBytes = bgraBytes // share the buffer
        // var flippedBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: flippedBytes),
        //                                   height: vImagePixelCount(texture.height), width: vImagePixelCount(texture.width), rowBytes: rowBytes)
        // vImageVerticalReflect_ARGB8888(&rgbaBuffer, &flippedBuffer, 0)
        
        // create CGImage with RGBA
        let colorScape = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
        guard let data = CFDataCreate(nil, rgbaBytes, length) else { return nil }
        guard let dataProvider = CGDataProvider(data: data) else { return nil }
        let cgImage = CGImage(width: texture.width, height: texture.height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: rowBytes,
                              space: colorScape, bitmapInfo: bitmapInfo, provider: dataProvider,
                              decode: nil, shouldInterpolate: true, intent: .defaultIntent)
        
        return UIImage(cgImage: cgImage!)
    }
}

UIImageとして保存する

前段までで無事にUnityからテクスチャを受け取ることができました。あとはこれをUIImageとしてファイルに保存すれば終了です。

シンプルに保存するだけでいいのであれば以下のように1行で書くことができます。

UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil);

なお、保存後にコールバックを受け取りたい場合は適切にセットアップして、コールバックを受け取るオブジェクトを生成する必要があります。
具体的には以下のシグネチャを持つオブジェクトを生成し、セレクタを渡してやることで実現できます。

期待されるシグネチャ

- (void)image:(UIImage *)image
    didFinishSavingWithError:(NSError *)error
                 contextInfo:(void *)contextInfo;

これを実装したオブジェクトを作ると以下のようになります。
(Unityへのコールバックをする部分も一緒に実装した例です)

#import "CaptureCallback.h"
 
@implementation CaptureCallback

- (id)initWithObjectName:(NSString *)_objectName
              methodName:(NSString *)_methodName;
{
    if (self = [super init])
    {
        self.objectName = _objectName;
        self.methodName = _methodName;
    }
    return self;
}
 
- (void)savingImageIsFinished:(UIImage *)_image didFinishSavingWithError:(NSError *)_error contextInfo:(void *)_contextInfo
{
    const char *objectName = [self.objectName UTF8String];
    const char *methodName = [self.methodName UTF8String];

    if (_error != nil)
    {
        NSLog(@"Error occurred with %@", _error.description);
        UnitySendMessage(objectName, methodName, [_error.description UTF8String]);
    }
    else
    {
        UnitySendMessage(objectName, methodName, "success");
    }
}
 
@end

これを実際に使うと以下のようになります。

CaptureCallback *callback = [[CaptureCallback alloc] initWithObjectName:@"obj" methodName:@"method"];

UIImageWriteToSavedPhotosAlbum(image, callback, @selector(savingImageIsFinished:didFinishSavingWithError:contextInfo:), nil);

詳細は以下のドキュメントをご覧ください。

developer.apple.com

保存パスをコールバックで受け取る

上記の関数では保存と保存後のコールバックを受け取れても、保存したファイルのパスを知ることができません。
これを実現するためにはPHPhotoLibraryperformChangesと、PHImageManagerrequestImageDataForAsset:を利用します。

コードは以下のようになります。
ポイントはいくつかの非同期関数を連続で呼び出し、最終的にファイルパスを取得している点です。

__block NSString* localId;

// Add it to the photo library
[PHPhotoLibrary.sharedPhotoLibrary performChanges:^{
    PHAssetChangeRequest *assetChangeRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:image];
    
    localId = assetChangeRequest.placeholderForCreatedAsset.localIdentifier;
} completionHandler:^(BOOL success, NSError *err) {
    
    if (!success)
    {
        NSLog(@"Error saving image: %@", err.localizedDescription);
        [callback savingImageIsFinished:nil
                didFinishSavingWithError:err];
    }
    else
    {
        PHFetchResult* assetResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[localId] options:nil];
        PHAsset *asset = assetResult.firstObject;
        [PHImageManager.defaultManager requestImageDataForAsset:asset
                                                        options:nil
                                                    resultHandler:^(NSData *imageData, NSString *dataUTI, UIImageOrientation orientation, NSDictionary *info) {
                                                        
                                                        NSURL *fileUrl = [info objectForKey:@"PHImageFileURLKey"];
                                                        
                                                        if (fileUrl)
                                                        {
                                                            NSLog(@"Image path: %@", fileUrl.relativePath);
                                                            [callback savingImageIsFinished:fileUrl
                                                                    didFinishSavingWithError:nil];
                                                        }
                                                        else
                                                        {
                                                            NSLog(@"Error retrieving image filePath, heres whats available: %@", info);
                                                            [callback savingImageIsFinished:nil
                                                                    didFinishSavingWithError:nil];
                                                        }
                                                    }];
    }
}];

やや複雑ですが、これで保存先のパスを得ることができます。

まとめ

ひとまずここまでで、Unity側で生成したテクスチャをネイティブ側に送りファイルに保存することができました。
今回は保存することを目的に作成しましたが、ネイティブ側で加工をしたり、ネイティブ側で生成したものをUnity側に送るなど、活用の幅は広いと思います。

ネイティブと友だちになれるとやれることが格段に増えるのでぜひともマスターしておきたいですね。

そして以下からは、今回のプラグインを作るにあたってハマった点や試したことなど、後々役に立ちそうなものをメモとして残しておきます。
興味がある方は読んでみてください。


その他の役立ちそうなメモ

Native Plugin Interfaceについて

最後に、今回の実装とは直接は関係ありませんが、デバイス情報(MTLDevice)などを利用したいケースがある場合にUnityのAPIからそれらデバイスへの参照の取得方法を紹介していきます。

これらはNative Plugin InterfaceとしてUnityから提供されているAPIになります。

バイスへの参照を取得する

バイスへの参照などを得るためにUnityはIUnityInterfacesというインターフェースを用意しています。
これはプラグインが読み込まれた際に呼ばれるコールバック内で取得することができます。

ドキュメントにはextern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoadを公開しておけば自動的に呼ばれる、と記載されているのですがiOSだとダメなのかこれだけでは呼び出されませんでした。

そこで別の方法を利用してこのインターフェースを取得するようにしました。

具体的には以下の関数をあらかじめ呼び出すことで対応しました。

UnityRegisterRenderingPluginV5(&UnityPluginLoad, &UnityPluginUnload);

名前から分かる通り、プラグインが読み込まれた際に、引数に渡したコールバックが呼ばれる仕組みになっています。
この登録処理を、C#側から呼べるようにしておき、Startなどのタイミングで呼び出しておきます。

これら諸々を記述したコードは以下のようになります。

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces);
extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload();

static IUnityGraphicsMetal* s_MetalGraphics = 0;
static IUnityInterfaces*    s_UnityInterfaces  = 0;
static IUnityGraphics*      s_Graphics = 0;

static bool initialized = false;

static void UNITY_INTERFACE_API OnGraphicsDeviceEvent(UnityGfxDeviceEventType eventType)
{
    switch (eventType)
    {
        case kUnityGfxDeviceEventInitialize:
        {
            // s_RendererType = s_Graphics->GetRenderer();
            initialized = false;
            break;
        }
        case kUnityGfxDeviceEventShutdown:
        {
            // s_RendererType = kUnityGfxRendererNull;
            initialized = false;
            break;
        }
        case kUnityGfxDeviceEventBeforeReset:
        {
            // TODO: User Direct3D 9 code
            break;
        }
        case kUnityGfxDeviceEventAfterReset:
        {
            // TODO: User Direct3D 9 code
            break;
        }
    };
}

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces)
{
    s_UnityInterfaces = unityInterfaces;
    s_Graphics = s_UnityInterfaces->Get<IUnityGraphics>();
    s_MetalGraphics   = s_UnityInterfaces->Get<IUnityGraphicsMetal>();

    s_Graphics->RegisterDeviceEventCallback(OnGraphicsDeviceEvent);
    OnGraphicsDeviceEvent(kUnityGfxDeviceEventInitialize);
}

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

///
/// Attach the functions to the callback of plugin loaded event.
///
extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API _AttachPlugin()
{
    UnityRegisterRenderingPluginV5(&UnityPluginLoad, &UnityPluginUnload);
}

特に大事な点は以下です。

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces)
{
    s_UnityInterfaces = unityInterfaces;
    s_Graphics = s_UnityInterfaces->Get<IUnityGraphics>();
    s_MetalGraphics   = s_UnityInterfaces->Get<IUnityGraphicsMetal>();

    s_Graphics->RegisterDeviceEventCallback(OnGraphicsDeviceEvent);
    OnGraphicsDeviceEvent(kUnityGfxDeviceEventInitialize);
}

コールバックにはIUnityInterfacesが引数として渡されてくるので、さらにそこからunityInterfaces->Get<IUnityGraphicsMetal>();を呼び出すことでデバイスへの参照を持つオブジェクトを取得することができます。

UNITY_INTERFACE_EXPORTとUNITY_INTERFACE_API

ちなみにAPIを利用するにはUNITY_INTERFACE_EXPORTUNITY_INTERFACE_APIのマクロを付ける必要があります。
ぱっと見はなにをしてくれるものか分かりづらいので、定義元をメモしておきます。

定義を見てみると以下のように分岐されています。

#if defined(__CYGWIN32__)
    #define UNITY_INTERFACE_API __stdcall
    #define UNITY_INTERFACE_EXPORT __declspec(dllexport)
#elif defined(WIN32) || defined(_WIN32) || defined(__WIN32__) || defined(_WIN64) || defined(WINAPI_FAMILY)
    #define UNITY_INTERFACE_API __stdcall
    #define UNITY_INTERFACE_EXPORT __declspec(dllexport)
#elif defined(__MACH__) || defined(__ANDROID__) || defined(__linux__)
    #define UNITY_INTERFACE_API
    #define UNITY_INTERFACE_EXPORT
#else
    #define UNITY_INTERFACE_API
    #define UNITY_INTERFACE_EXPORT
#endif

プラットフォームによっては関数をDLLにexportするために必要な指定が必要なため、また指定方法が異なるためこういうスイッチが入っています。

iOSなどその他のプラットフォームでは特になにも出力されないので、読むときは単純に関数の宣言と見ておいて大丈夫です。

ちなみに__stdcallは関数の呼び出し規約となっていて、アセンブラレベルでは関数に対して引数をどう渡すか、また戻り値をどう受け取るか、という取り決めを事前にしておく必要があります。

その取り決めを明示するものであり、これがないと引数がうまく渡せなかったり、など問題がおきます。(多分、誤った指定だとエラーになるか、実行時にクラッシュします。試したこと無いので推測ですが・・・)

このあたりについては以下の記事が参考になるかもしれません。

qiita.com

ハマった点

Photo Libraryへのアクセス権

Photo Libraryに保存するためにアクセス権を取得する必要があります。
実行時にアクセス権を確認し、アクセス権がなければ適切にリクエストする必要があります。

ちなみに確認せずに実行するとクラッシュして以下のようなエラーが出力されます。

$ Photos Access not allowed

このあたりについては以下の記事を参考に修正しました。

superhahnah.com

その他メモ

Build Settingsをポストプロセスで更新する

Swiftコードも含んでいるためBuild Settingsを修正する必要があり、それを自動化するために以下の記事を参考にさせていただきました。

uwanosora22.hatenablog.com

MTLTextureをコピーする

実装する過程でクラッシュが発生したため、いったんテクスチャをネイティブ側でコピーして保持したらどうだ、っていうことで作ったものがあるのでメモとして残しておきます。

id<MTLTexture> CopyTexture(id<MTLTexture> source)
{
    MTLTextureDescriptor *descriptor = [[MTLTextureDescriptor alloc] init];
    descriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
    descriptor.width = source.width;
    descriptor.height = source.height;
    
    id<MTLTexture> texture = [s_MetalGraphics->MetalDevice() newTextureWithDescriptor:descriptor];

    id<MTLCommandQueue> queue = [s_MetalGraphics->MetalDevice() newCommandQueue];
    id<MTLCommandBuffer> buffer = [queue commandBuffer];
    id<MTLBlitCommandEncoder> encoder = [buffer blitCommandEncoder];
    [encoder copyFromTexture:source
                 sourceSlice:0
                 sourceLevel:0
                sourceOrigin:MTLOriginMake(0, 0, 0)
                  sourceSize:MTLSizeMake(source.width, source.height, source.depth)
                   toTexture:texture
            destinationSlice:0
            destinationLevel:0
           destinationOrigin:MTLOriginMake(0, 0, 0)];
    [encoder endEncoding];
    [buffer commit];
    [buffer waitUntilCompleted];

    return texture;
}

MTLTextureをネイティブ側で生成してポインタを受け取る

今回の実装をしていく過程で、試しにネイティブ側でMTLTextureを生成してUnity側に渡したらうまくいくかも、と思って実装したのでメモとして残しておきます。

extern "C" uintptr_t UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API _GetNativeTexturePtr(int width, int height)
{
    MTLTextureDescriptor *descriptor = [[MTLTextureDescriptor alloc] init];
    descriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
    descriptor.width = width;
    descriptor.height = height;
    
    id<MTLTexture> texture = [s_MetalGraphics->MetalDevice() newTextureWithDescriptor:descriptor];

    return (uintptr_t)texture;
}

その他参考にした記事

tips.hecomi.com

Unity AR FoundationでPeople Occlusionをやってみる

この記事はUnityゆるふわサマーアドベントカレンダー 2019 #ゆるふわアドカレの8/11の記事です。

概要

UnityのPackage Managerで提供されているAR Foundation。最近はARKit3のサポートが入ってだいぶ熱くなってきてますね。

ということで、今回はAR Foundationを使ってPeople Occlusionをやってみたのでそのメモです。

AR Foundationのドキュメントはこちら↓

docs.unity3d.com

AR Foundationについてはサンプルなどを含めたものがGitHubで公開されています。

github.com

このサンプルを使ってPeople Occlusion的なものを実装してみたのが以下↓

処理フロー

今回は上のtweetのような簡単な見た目のPeople Occlusionを行うまでを解説したいと思います。
大まかなフローはそこまで複雑なことはしていません。

  • 1) People Occlusionに必要なテクスチャを集める
    • 1-1) ステンシル / デプス / カメラ映像のテクスチャ
  • 2) ARオブジェクトと人との深度を比較する
  • 3) ポストエフェクトとして描画する

People Occlusionに必要なテクスチャを集める

フローの(1)についてはAR Foundationが提供してくれているARHumanBodyManagerクラスとARCameraBackgroundから得ることができます。

ARHumanBodyManagerARCameraBackgroundについてはインスペクタなどから設定し、以下のようにすることでテクスチャを得ることができます。

ステンシルとデプス用のテクスチャを得る

// 人の位置と推測された位置が`1`となるマスク用テクスチャ
Texture2D humanStencil = _humanBodyManager.humanStencilTexture;

// 人の位置と推測された位置の深度値を格納しているテクスチャ(単位はメートルの模様)
Texture2D humanDepth = _humanBodyManager.humanDepthTexture;

ステンシルとデプスはARHumanBodyManagerのプロパティから簡単に得ることができます。

カメラからの映像については少し手間を掛ける必要があります。

ARCameraの映像を得る

カメラからの映像についてはARCameraBackgroundクラスを利用します。
ちなみにカメラからの映像が必要な理由は、レンダリング結果にはARオブジェクトも含まれてしまっているので、カメラの映像も別途必要なのです。(もしかしたらデプステクスチャをデプスバッファとして応用することができればこの処理はいらないかもしれません)

対象の映像を得るための方法がドキュメントに記載されています。それを引用すると以下のように説明されています。

Copying the Camera Texture to a Render Texture

The camera textures are likely External Textures and may not last beyond a frame boundary. It can be useful to copy the camera image to a Render Texture for persistence or further processing. This code will blit the camera image to a render texture of your choice:

Graphics.Blit(null, m_MyRenderTexture, m_ARBackgroundCamera.material);

このコード断片が示すように、自前で生成したレンダーテクスチャとARCameraBackgroundクラスのmaterialプロパティを利用して、現在の状態をそのレンダーテクスチャに書き出すことができます。

ARオブジェクトと人との深度を比較する

さて、必要なデータを集めたらそれを利用して「人と思しき場所」の深度を比較し、必要であればARオブジェクトの前面に人の体を描画します。

比較に関してはシェーダを利用し以下のようにします。

// Check depth delta. If delta is over zero, it means pixels that estimated like human is in front of AR objects.
float sceneZ = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv));
float delta = saturate(sceneZ - depth);
if (delta > 0.0)
{
    return tex2D(_BackgroundTex, i.uv);
}
else
{
    return col;
}

最初の行で行っているのは、3Dシーンで描画されたデプスバッファからの値をリニアに変換しています。

ここでリニアに変換しているのは以下の記事で言及されている以下の理由からです。

https://forum.unity.com/threads/how-to-setup-people-occlusion.691789/

The values in the depth buffer are in meters with the range [0, infinity) and need to be converted into the view space with the depth value [0, 1] mapped between the near & far clip plane.

正確には「デプステクスチャ側を0 - 1に正規化する」と書かれていますが、Unityの単位もメートルなのでそのままリニアに変換することでメートルとして利用できるかなと思ってこうしています。

が、floatの精度などの問題でもしかしたら上で言及されているように、デプステクスチャ側をしっかり0 - 1に正規化したほうがよりきれいになるかもしれません。(それは追って調査)

なお、このあたりの変換については以前書いた記事が理解に役立つかもしれません。

edom18.hateblo.jp

ポストエフェクトとして描画する

最後に、これらの情報を元にしてポストフェクトとしてPeople Occlusionを実現していきます。
イメージ的にはCGシーンとカメラ映像を、深度値を元に切り分けてレンダリングする、という感じです。

ということでセットアップを含めてコード全文を載せておきます。
まずはシェーダから。

Shader "Hidden/PeopleOcclusion"
{
    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;
            sampler2D _BackgroundTex;
            sampler2D _DepthTex;
            sampler2D _StencilTex;

            UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);

                float2 uv = i.uv;

                // Flip x axis.
                uv.x = 1.0 - uv.x;

                // Correcting textures ratio that can be got by ARHumanBodyManager to the screen ratio.
                float ratio = 1.62;
                uv.y /= ratio;
                uv.y += 1.0 - (ratio * 0.5);

                float stencil = tex2D(_StencilTex, uv).r;
                if (stencil < 0.9)
                {
                    return col;
                }

                // Check depth delta. If delta is over zero, it means pixels that estimated like human is in front of AR objects.
                float depth = tex2D(_DepthTex, uv).r;
                float sceneZ = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv));
                float delta = saturate(sceneZ - depth);
                if (delta > 0.0)
                {
                    return tex2D(_BackgroundTex, i.uv);
                }
                else
                {
                    return col;
                }
            }
            ENDCG
        }
    }
}

コードは深度値比較とフェッチする対象を変えるだけなのでそんなに長くないです。
前述の通り、重要な箇所は以下。

// Check depth delta. If delta is over zero, it means pixels that estimated like human is in front of AR objects.
float depth = tex2D(_DepthTex, uv).r;
float sceneZ = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv));
float delta = saturate(sceneZ - depth);
if (delta > 0.0)
{
    return tex2D(_BackgroundTex, i.uv);
}

ひとつ注意点として、UV値を少しだけ加工しています。
理由は、AR Foundationから得られるステンシルとデプステクスチャのサイズがデバイスの解像度と合っていないためです。(さらに左右反転しているのでそれも合わせて行っています)

そのため少しだけUVの値を加工して縦横比が合うように補正しています。
補正のためのコードは以下。

// Correcting textures ratio that can be got by ARHumanBodyManager to the screen ratio.
float ratio = 1.62;
uv.y /= ratio;
uv.y += 1.0 - (ratio * 0.5);

1.62の根拠は256x192の比率から2688x1242の比率へ変換するためのものです。
ちなみに、ステンシル/デプステクスチャの解像度はstandard resolutionhalf resolution, full resolutionの3つが選べますが、解像度が違えどどれも比率は同様のものが渡されるのでこの計算で問題なさそうです。

あとはAR Foundationから受け取ったデプステクスチャの値とレンダリングされた3Dシーンの深度値を比較して、人と思われる位置のピクセルの深度が3Dシーンより手前だと判断されたらカメラの映像を利用し、そうでなければそのまま3Dシーンの映像をレンダリングするという形です。

これをセットアップしているC#側のコードは以下のようになります。

using System.Text;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;

public class PeopleOcclusion : MonoBehaviour
{
    [SerializeField, Tooltip("The ARHumanBodyManager which will produce frame events.")]
    private ARHumanBodyManager _humanBodyManager;

    [SerializeField]
    private Material _material = null;

    [SerializeField]
    private ARCameraBackground _arCameraBackground = null;

    [SerializeField]
    private RawImage _captureImage = null;

    private RenderTexture _captureTexture = null;

    public ARHumanBodyManager HumanBodyManager
    {
        get { return _humanBodyManager; }
        set { _humanBodyManager = value; }
    }

    [SerializeField]
    private RawImage _rawImage;

    /// <summary>
    /// The UI RawImage used to display the image on screen.
    /// </summary>
    public RawImage RawImage
    {
        get { return _rawImage; }
        set { _rawImage = value; }
    }

    [SerializeField]
    private Text _imageInfo;

    /// <summary>
    /// The UI Text used to display information about the image on screen.
    /// </summary>
    public Text ImageInfo
    {
        get { return _imageInfo; }
        set { _imageInfo = value; }
    }

    #region ### MonoBehaviour ###
    private void Awake()
    {
        Camera camera = GetComponent<Camera>();
        camera.depthTextureMode |= DepthTextureMode.Depth;

        _rawImage.texture = _humanBodyManager.humanDepthTexture;

        _captureTexture = new RenderTexture(Screen.width, Screen.height, 0);
        _captureImage.texture = _captureTexture;
    }
    #endregion ### MonoBehaviour ###

    private void LogTextureInfo(StringBuilder stringBuilder, string textureName, Texture2D texture)
    {
        stringBuilder.AppendFormat("texture : {0}\n", textureName);
        if (texture == null)
        {
            stringBuilder.AppendFormat("   <null>\n");
        }
        else
        {
            stringBuilder.AppendFormat("   format : {0}\n", texture.format.ToString());
            stringBuilder.AppendFormat("   width  : {0}\n", texture.width);
            stringBuilder.AppendFormat("   height : {0}\n", texture.height);
            stringBuilder.AppendFormat("   mipmap : {0}\n", texture.mipmapCount);
        }
    }

    private void Update()
    {
        var subsystem = _humanBodyManager.subsystem;

        if (subsystem == null)
        {
            if (_imageInfo != null)
            {
                _imageInfo.text = "Human Segmentation not supported.";
            }
            return;
        }

        StringBuilder sb = new StringBuilder();
        Texture2D humanStencil = _humanBodyManager.humanStencilTexture;
        Texture2D humanDepth = _humanBodyManager.humanDepthTexture;
        LogTextureInfo(sb, "stencil", humanStencil);
        LogTextureInfo(sb, "depth", humanDepth);

        if (_imageInfo != null)
        {
            _imageInfo.text = sb.ToString();
        }

        _material.SetTexture("_StencilTex", humanStencil);
        _material.SetTexture("_DepthTex", humanDepth);
        _material.SetTexture("_BackgroundTex", _captureTexture);
    }

    private void LateUpdate()
    {
        if (_arCameraBackground.material != null)
        {
            Graphics.Blit(null, _captureTexture, _arCameraBackground.material);
        }
    }

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, _material);
    }
}

こちらのコードはAR FoundationのTestDepthImage.csに手を加えたものです。
元々あったステンシルとデプスを取得する部分にカメラからの映像取得を追加したものです。

そしてこれらのテクスチャ情報をマテリアルにセットしOnRenderImageのタイミングで合成している、というわけです。

ちなみにOnRenderImageを利用していることからも分かる通り、これはCameraコンポーネントがついているオブジェクトにアタッチして利用することを想定しています。

まとめ

色々調整が必要な部分が多々ありますが、ひとまずAR Foundationの機能を用いてPeople Occlusionが実現できました。
これを調整していけば実用的なものになりそうです。

People Occlusionは個人的にARKit3で一番注目している機能なので、いち早く取り入れて実際のコンテンツに利用していきたいですね。