e.blog

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

シャボン玉風シェーダを実装してみたのでメモ

概要

今回の実装はこちらの論文(汎用的な構造色レンダリング手法の開発)を参考に実装したものになります。
ただ理解が足りていない部分や勘違いなどあるかもしれないので、もし間違いなどあれば指摘いただけると嬉しいです。

また今回の実装にあたってこちらのブログも参考にさせていただきました。

light11.hatenadiary.com

実装して実行した結果がこちら↓

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

github.com

論文概要

まずはざっくり概要を。
最近の3DCGのレンダリングでは様々な物質の光の反射をモデル化して、それを表す近似した関数を用いてレンダリングを行います。
レンダリング方程式、なんかでググると色々出てきます。いわゆる「物理ベースレンダリング」ってやつですね。

この記事が参考になるかも?↓

https://qiita.com/mebiusbox2/items/e7063c5dfe1424e0d01aqiita.com

そして物理ベースレンダリングを調べていると「BRDF」という言葉が出てきます。
(ちなみに自分も以前、調べて記事を書いたのでよかったらこちらもどうぞ↓)

qiita.com

また、こちらのシリーズ記事もとても良いです↓

qiita.com

なぜ物理ベースレンダリング

なぜこれだけ物理ベースレンダリングの記事を紹介したかと言うと、今回の論文の中で「BRDF」という言葉が出てきます。

BRDFは「Bidirectional Reflectance Distribution Function」の頭文字を取ったものです。

BRDFについては以下の記事が詳細に書かれています。(数式めっちゃ出てくるのでうってなるかもですが・・)

rayspace.xyz

このBRDF、前述した「物質の光の反射をモデル化」したものです。上記記事から引用させていただくと、

BRDFは物体表面の位置、入射方向、反射方向を変数とする6次元関数で、反射に関する物体表面の特性を表す。

というもの。つまりは「関数」ってことです。

構造色をテクスチャフェッチで解決する

さて、BRDFは光の反射をモデル化したものと書きましたが、現在のレンダリング方程式ではすべての物質の光の反射を扱えるほど精巧には出来ていません。ある一定の条件でのみ成り立つものだったり、特定の表現のための関数などもあります。それを匠に使いこなしながら、今日のフォトリアリスティックな映像は作られているというわけですね。

そして今回の論文ではそのBRDF的な関数を提案するものではありません

複雑な関数から導き出すのではなくテクスチャに情報を格納し、そこから構造色(詳細は後述)を決定しよう、というものです。

構造色とは

論文の説明から引用させてもらうと、

構造色は微細構造がもたらす光学現象による発色であるため、視点位置や照明環境に応じて色が変化するという特徴がある。

シャボン玉やCDの記録面を見ると分かると思いますが、見る角度や照明の状態に応じて色が変化しますよね。ああいう、視点や光源によって変化する色を「構造色」と言うようです。

構造色の仕組み

構造色は前述のように、視点と光源位置によって見え方が変わるものです。ではなぜ、視点や光源位置に応じて色が変化するのでしょうか。

論文から画像を引用させてもらうと以下のような構造になっているものが「構造色」を表します。

膜やスリットなどに光が入り、入射した光と反射した光の「光路」に差が生じると、別の反射光と干渉を起こして色が変化する、というのが理由のようです。(光は波の性質も持っているのでこうした干渉が起こる)

光の反射

反射光の話が出ました。これはまさに、前述のBRDFの話と同じですね。
3DCGでは光の反射をどう扱うかで色が決まります。

そして今、入射光と反射光について以下のように定義します。

上図は論文から引用したものです。
これは、注目している点の光の入射・反射を \theta  \phi によって表した図です。
入射光の  \theta \phi、そして反射光の \theta \phiの4つのパラメータがあります。

この4パラメータが大事になります。

構造色を決定する

さて、大まかに概観したところで実際に構造色を決定する部分を見ていきましょう。
前述のように、入射・反射の角度の4パラメータが大事なポイントです。

また論文から画像を引用させていただくと構造色を決定するフローは以下のようになります。

視点位置 E、光源位置 L、それによって決定する (\theta_i, \phi_i),  (\theta_o, \phi_o)の4パラメータ、そしてUV値がどのように使われるかを示した図です。

続いて以下の図を見てください。

パラメータをどう扱うかを図示したものです。

詳細を説明する前にさらに次の図を見てください。

こちらは実際にレンダリングに使用するテクスチャです。
左上がシャボン玉の厚みテクスチャで、UV値を利用して単純にアクセスされるテクスチャです。

次に右上が光路差を格納したテクスチャで、視点・光源の角度によってアクセスされるテクスチャです。

そして下の最後のものが構造色テクスチャです。
最終的に色としてマッピングされるのはこの構造色テクスチャです。

つまり、下部の構造色テクスチャのどの位置をフェッチしたらいいか、を光路差情報を元に決定するのが実装概要となります。

構造色テクスチャのフェッチ位置を計算

さてではどのようにしてフェッチ位置を決定するのか。それが、ふたつ前に載せた画像です。再掲すると以下の画像です。

左の図は通常のUV値を利用して「厚みテクスチャ」にアクセスする様子を描いています。
場所依存の「厚み」を表現しているわけですね。ある意味で法線マップと同様のことをやっています。なのでここは細かい説明は不要でしょう。

右側のテクスチャが今回の肝である「光路差テクスチャ」です。
前述した3枚のテクスチャのうち、「視点・光源依存テクスチャ」と書かれているテクスチャがそれです。

そしてこのテクスチャから値を取得するわけですが、「どこの値を取り出すのか」を決定するのが、前段で大事だと話した4つのパラメータ、つまり (\theta_i, \phi_i),  (\theta_o, \phi_o)です。

論文では以下のように説明されています。

視点・光源依存テクスチャは、視点および光源位置による光路差の変化を表現するテクスチャである。テクスチャは図3.3の座標軸をもち、横軸に法線成分、縦軸に接線成分が対応している。ただしTは接線ベクトルであり、テクスチャ座標のUの方向を持つ単位ベクトルである。左半分が光源位置による光路差であり、光源 Lにより L \cdot Nおよび L \cdot Tが決まる。同様に右半分が視点位置による光路差であり、視点 Eにより E \cdot Nおよび E \cdot Tが決まる。よってテクスチャの中心がオブジェクトの面に垂直な位置、つまり法線上に視点・光源がある場合に対応する。

特に、

テクスチャの中心がオブジェクトの面に垂直な位置、つまり法線上に視点・光源がある場合に対応する。

を見ると、テクスチャの中心が視点・光源がともに法線上にある場合に対応することになります。

計算してみましょう。法線上ということは( L \cdot N = 1 L \cdot T = 0)同様に( E \cdot N = 1 E \cdot T = 0)となります。
この値が、視点・光源依存テクスチャのどこにあるかを見てみると確かにテクスチャの中心にマッピングされますね。

さて、ではこの事実をどう利用するのでしょうか。考え方はこうです。
視点・光源依存テクスチャは0 ~ 1の正規化された値が格納されています。値はただのfloatですね。なのでスカラーです。しかしテクスチャから色をフェッチするためにはUV、つまりfloat2のベクトルとしての値が必要です。

つまり、入射光の (\theta_i, \phi_i)のふたつのパラメータによってひとつのスカラーが得られます。同様に反射光の (\theta_o, \phi_o)のふたつのパラメータによってひとつのスカラーが得られます。

このふたつのスカラーを利用すればUVとしてのベクトルを得ることができますね。

論文の別の画像を引用させてもらうと以下のように説明されています。

つまり入射光の角度から算出されたスカラーをU軸に、反射光の角度から算出されたスカラーをV軸に割り当てることで構造色が決定できる、というわけですね。

そしてそれを実際に実行した結果がこちら↓

値を計算しているところのコードを抜粋すると以下になります。

float d = /* Calculate thickness */

float u, v;

// Calculate U.
{
    float ln = dot(i.lightDir, i.normal);
    ln = (ln * 0.5) * d;

    float lt = dot(i.lightDir, i.tangent);
    lt = ((lt + 1.0) * 0.5) * d;

    u = tex2D(_LETex, float2(ln, lt)).x;
}

// Calculate V.
{
    float en = dot(i.viewDir, i.normal);
    en = ((1.0 - en) * 0.5 + 0.5) * d;

    float et = dot(i.viewDir, i.tangent);
    et = ((et + 1.0) * 0.5) * d;

    v = tex2D(_LETex, float2(en, et)).x;
}

Uの値は入射光(i.lightDir)によって計算され、Vの値は反射光(i.viewDir)によって計算されていますね。
ここで求まったUVの値を使って構造色テクスチャから色をフェッチしてあげれば晴れて、視点・光源に依存した色、つまり構造色を決定することができる、というわけです。

最後に

構造色の計算については以上です。
今回はそれ以外にもシンプルなPhong反射とリムライティングを追加しています。なので全部が正確なレンダリング、というわけではないですがそれなりに説得力のある絵になったかなと思います。

コード全体は長くないので全文を掲載しておきます。実際に動作するものを見たい場合はGitHubからダウンロードしてご覧ください。

Shader "Unlit/Bubble"
{
    Properties
    {
        [PowerSlider(0.1)] _F0("F0", Range(0, 1)) = 0.02
        _RimLightIntensity ("RimLight Intensity", Float) = 1.0
        _Color ("Color", Color) = (1, 1, 1, 1)
        _MainTex ("Texture", 2D) = "white" {}
        _DTex ("D Texture", 2D) = "gray" {}
        _LETex ("LE Texture", 2D) = "gray" {}
        _CubeMap ("Cube Map", Cube) = "white" {}
    }

    SubShader
    {
        Tags
        {
            "RenderType"="Transparent"
            "Queue"="Transparent"
        }
        LOD 100

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "./PerlinNoise.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float3 tangent : TANGENT;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : TEXCOORD1;
                float3 tangent : TEXCOORD2;
                float3 viewDir : TEXCOORD3;
                float3 lightDir : TEXCOORD4;
                half fresnel : TEXCOORD5;
                half3 reflDir : TEXCOORD6;
            };

            sampler2D _MainTex;
            sampler2D _DTex;
            sampler2D _LETex;

            UNITY_DECLARE_TEXCUBE(_CubeMap);

            float _F0;
            float _RimLightIntensity;
            float4 _MainTex_ST;
            fixed4 _Color;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.normal = v.normal;
                o.tangent = v.tangent;
                o.viewDir  = normalize(ObjSpaceViewDir(v.vertex));
                o.lightDir = normalize(ObjSpaceLightDir(v.vertex));
                o.fresnel = _F0 + (1.0h - _F0) * pow(1.0h - dot(o.viewDir, v.normal.xyz), 5.0);
                o.reflDir = mul(unity_ObjectToWorld, reflect(-o.viewDir, v.normal.xyz));
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                i.uv = pow((i.uv * 2.0) - 1.0, 2.0);
                //float d = tex2D(_DTex, i.uv + _Time.xy * 0.1);
                float d = perlinNoise((i.uv + _Time.xy * 0.1) * 3.0);

                float u, v;

                // Calculate U.
                {
                    float ln = dot(i.lightDir, i.normal);
                    ln = (ln * 0.5) * d;

                    float lt = dot(i.lightDir, i.tangent);
                    lt = ((lt + 1.0) * 0.5) * d;

                    u = tex2D(_LETex, float2(ln, lt)).x;
                }

                // Calculate V.
                {
                    float en = dot(i.viewDir, i.normal);
                    en = ((1.0 - en) * 0.5 + 0.5) * d;

                    float et = dot(i.viewDir, i.tangent);
                    et = ((et + 1.0) * 0.5) * d;

                    v = tex2D(_LETex, float2(en, et)).x;
                }

                float2 uv = float2(u, v);
                float4 col = tex2D(_MainTex, uv);

                float NdotL = dot(i.normal, i.lightDir);
                float3 localRefDir = -i.lightDir + (2.0 * i.normal * NdotL);
                float spec = pow(max(0, dot(i.viewDir, localRefDir)), 10.0);

                float rimlight = 1.0 - dot(i.normal, i.viewDir);

                fixed4 cubemap = UNITY_SAMPLE_TEXCUBE(_CubeMap, i.reflDir);
                cubemap.a = i.fresnel;

                col *= cubemap;
                col += rimlight * _RimLightIntensity;
                col += spec;

                return col;
            }
            ENDCG
        }
    }
}

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