e.blog

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

uGUIの背景をぼかしてオシャレに見せる

概要

最近、Apex Legendsにハマって毎日のように時間が吸われていってます。(まずい)

さて、今回はこのApex LegendsのUIで使われているような「背景がボケているUI」を作る方法を書いていきたいと思います。

↓こんな感じで、背景が透過+ボケている「すりガラス」風のUIですね。
f:id:edo_m18:20190402152514p:plain

↓実際に実装した動画はこんな感じ

背景がぼけているだけでなんか途端にオシャレ感でますよねw

今回のサンプルはGithubにアップしてあります。

github.com



フロー

今回の実装のフローは大まかに以下のような感じです。

  1. CommandBufferを使って画面をキャプチャする
  2. キャプチャした画像をブラー用のシェーダで加工する(※1)
  3. ブラーをかけた画像をuGUI用シェーダで合成する(※2)

という流れになります。

※1 ... ブラー画像を生成する方法については以前の記事で書いたのでそちらを参照ください。

edom18.hateblo.jp

CommandBufferを使って画面をキャプチャしブラーをかける

まず最初に行うのはCommandBufferによる画面キャプチャです。

CommandBuffer自体については以下のドキュメント凹みさんの記事にさらに詳しく書いてあるのでそちらも参考にしてみてください。

docs.unity3d.com

tips.hecomi.com

CommandBufferとは

CommandBufferとは、ドキュメントから引用すると以下のように記述されています。

これは、いわゆる “command buffers” で、Unity の Unity のレンダリングパイプライン を拡張することができます。 コマンドバッファがレンダリングコマンド(“set render target, draw mesh, …”)のリストを保持し、 カメラがレンダリング中にさまざまなポイントで実行するように設定することができます。

GPUは「コマンドバッファ」と呼ばれる、CPUからGPUに対して命令を送るためのバッファの仕組みがあります。いわゆると書いているのは、まさにこのことと同じことを指しているのだと思います。

なお、このあたりのGPUのコマンドバッファに関しては以下の記事が分かりやすいでしょう。興味がある人は読んでみてください。

blogs.msdn.microsoft.com

コマンドバッファとは、ざっくりと言うとCPUからGPUへ「こういう処理をしてから描画をこういうデータでやってね」という「コマンドを積み重ねたバッファ」、ということができます。


余談

余談ですが、なぜコマンドバッファという仕組みがあるのでしょうか。
CPUとGPUではその処理速度や実行するタスクなどが違うため協調して動くことができません。

そのため、CPU側で描画に必要なデータを準備し必要なタスクの順番などを定義したあと、それらをGPUに「依頼」する必要があります。

実際の仕事に置き換えて考えてみると、専門的な仕事に関してはその専門家に依頼することが自然な流れでしょう。
そして依頼したあとはその仕事が終わるまで待つことはせず、自分の仕事に戻るのが普通です。

これと似たようなことがCPUとGPUとの間で行われているわけです。
つまり、コマンドバッファにはGPUにやってほしいことを列挙し、それをGPUに渡して「レンダリング」という専門タスクを依頼するわけです。

そうして仕事の例と同じように、GPUにタスクを依頼したあとはCPUは自分のタスクに戻ります。
これが、「コマンドバッファ」と呼ばれるタスクのバッファを橋渡しとして利用する理由です。

このあたりのハード面からの理屈やGPUの仕組みについては以下の書籍がとても参考になりました。
興味がある方は読んでみると、GPU周りについて詳しくなるのでオススメです。


閑話休題

Unityの場合はCommandBufferはカメラに対して設定するようになっています。
まずはコードを見てみましょう。

CommandBufferをセットアップしているC#コード

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

namespace Example.uGUI
{
    /// <summary>
    /// Screen capture using CommandBuffer.
    /// </summary>
    public class SceneBlurEffect : MonoBehaviour
    {
        [SerializeField]
        private Shader _shader;

        [SerializeField, Range(1f, 100f)]
        private float _offset = 1f;

        [SerializeField, Range(10f, 1000f)]
        private float _blur = 100f;

        [SerializeField, Range(0f, 1f)]
        private float _intencity = 0;

        [SerializeField]
        private CameraEvent _cameraEvent = CameraEvent.AfterImageEffects;

        private Material _material;

        private Dictionary<Camera, CommandBuffer> _cameras = new Dictionary<Camera, CommandBuffer>();

        private float[] _weights = new float[10];
        private bool _enabledBlur = false;
        private bool _isInitialized = false;

        public float Intencity
        {
            get { return _intencity; }
            set { _intencity = value; }
        }

        private int _copyTexID = 0;
        private int _blurredID1 = 0;
        private int _blurredID2 = 0;
        private int _weightsID = 0;
        private int _intencityID = 0;
        private int _offsetsID = 0;
        private int _grabBlurTextureID = 0;

        private void Awake()
        {
            // OnWillRenderObjectが呼ばれるように、MeshRendererとMeshFilterを追加する
            MeshFilter filter = gameObject.AddComponent<MeshFilter>();
            filter.hideFlags = HideFlags.DontSave;
            MeshRenderer renderer = gameObject.AddComponent<MeshRenderer>();
            renderer.hideFlags = HideFlags.DontSave;

            _copyTexID = Shader.PropertyToID("_ScreenCopyTexture");
            _blurredID1 = Shader.PropertyToID("_Temp1");
            _blurredID2 = Shader.PropertyToID("_Temp2");
            _weightsID = Shader.PropertyToID("_Weights");
            _intencityID = Shader.PropertyToID("_Intencity");
            _offsetsID = Shader.PropertyToID("_Offsets");
            _grabBlurTextureID = Shader.PropertyToID("_GrabBlurTexture");

            Transform parent = Camera.main.transform;
            transform.SetParent(parent);
            transform.localPosition = Vector3.forward;

            UpdateWeights();
        }

        private void Update()
        {
            foreach (var kv in _cameras)
            {
                kv.Value.Clear();
                BuildCommandBuffer(kv.Value);
            }
        }

        private void OnEnable()
        {
            Cleanup();
        }

        private void OnDisable()
        {
            Cleanup();
        }

        public void OnWillRenderObject()
        {
            if (!gameObject.activeInHierarchy || !enabled)
            {
                Cleanup();
                return;
            }

            if (_material == null)
            {
                _material = new Material(_shader);
                _material.hideFlags = HideFlags.HideAndDontSave;
            }

            Camera cam = Camera.current;
            if (cam == null)
            {
                return;
            }

#if UNITY_EDITOR
            if (cam == UnityEditor.SceneView.lastActiveSceneView.camera)
            {
                return;
            }
#endif

            if (_cameras.ContainsKey(cam))
            {
                return;
            }

            // コマンドバッファ構築
            CommandBuffer buf = new CommandBuffer();
            buf.name = "Blur AR Screen";
            _cameras[cam] = buf;

            BuildCommandBuffer(buf);

            cam.AddCommandBuffer(_cameraEvent, buf);
        }

        private void BuildCommandBuffer(CommandBuffer buf)
        {
            buf.GetTemporaryRT(_copyTexID, -1, -1, 0, FilterMode.Bilinear);
            buf.Blit(BuiltinRenderTextureType.CurrentActive, _copyTexID);

            // 半分の解像度で2枚のRender Textureを生成
            buf.GetTemporaryRT(_blurredID1, -2, -2, 0, FilterMode.Bilinear);
            buf.GetTemporaryRT(_blurredID2, -2, -2, 0, FilterMode.Bilinear);

            // 半分にスケールダウンしてコピー
            buf.Blit(_copyTexID, _blurredID1);

            // コピー後はいらないので破棄
            buf.ReleaseTemporaryRT(_copyTexID);

            float x = _offset / Screen.width;
            float y = _offset / Screen.height;

            buf.SetGlobalFloatArray(_weightsID, _weights);
            buf.SetGlobalFloat(_intencityID, Intencity);

            // 横方向のブラー
            buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0));
            buf.Blit(_blurredID1, _blurredID2, _material);

            // 縦方向のブラー
            buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0));
            buf.Blit(_blurredID2, _blurredID1, _material);

            buf.SetGlobalTexture(_grabBlurTextureID, _blurredID1);
        }

        private void Cleanup()
        {
            foreach (var cam in _cameras)
            {
                if (cam.Key != null)
                {
                    cam.Key.RemoveCommandBuffer(_cameraEvent, cam.Value);
                }
            }

            _cameras.Clear();
            Object.DestroyImmediate(_material);
        }

        private void OnValidate()
        {
            if (!Application.isPlaying)
            {
                return;
            }

            UpdateWeights();
        }

        public void EnableBlur(bool enabled)
        {
            _enabledBlur = enabled;
        }

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

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

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

ちょっと長いので面食らってしまった方もいるかもしれませんが、大事な点は以下です。

  • CommandBufferを生成し、必要な処理(コマンド)をバッファに設定する
  • セットアップしたCommandBufferをカメラに設定する

という2点のみです。

画面のキャプチャとブラー処理

これを行っているところを抜粋すると以下の部分になります。

public void OnWillRenderObject()
{
    // レンダリング対象オブジェクトが非表示の場合はコマンドバッファをクリアして終了
    if (!gameObject.activeInHierarchy || !enabled)
    {
        Cleanup();
        return;
    }

    // コマンドバッファのレンダリングで使用するマテリアルをシェーダファイルから生成する
    if (_material == null)
    {
        _material = new Material(_shader);
        _material.hideFlags = HideFlags.HideAndDontSave;
    }

    // 現在、このコンポーネントを持っているオブジェクトをレンダリングしようとしているカメラへの参照
    // (メインカメラひとつなら通常は一回だけ呼ばれる)
    Camera cam = Camera.current;
    if (cam == null)
    {
        return;
    }

    // 対象カメラに対してすでにコマンドバッファが生成済みなら終了
    if (_cameras.ContainsKey(cam))
    {
        return;
    }

    // コマンドバッファ構築
    CommandBuffer buf = new CommandBuffer();

    // あとでデバッグするときに分かりやすいように名前をつけておく
    buf.name = "Blur AR Screen";

    // 生成したコマンドバッファをキャッシュする
    _cameras[cam] = buf;

    // コマンドバッファに対して必要な設定を行う
    BuildCommandBuffer(buf);

    // 対象カメラに対してコマンドバッファを登録する
    cam.AddCommandBuffer(_cameraEvent, buf);
}

// 実際のコマンドバッファの構築処理
private void BuildCommandBuffer(CommandBuffer buf)
{
    // テンポラリのスクリーンサイズと同じサイズのRenderTextureを取得する(-1の指定がスクリーンサイズと同じサイズを意味する)
    buf.GetTemporaryRT(_copyTexID, -1, -1, 0, FilterMode.Bilinear);

    // 現在のアクティブなRenderTextureから、取得したテンポラリのRenderTextureへ単純にコピーする
    buf.Blit(BuiltinRenderTextureType.CurrentActive, _copyTexID);

    // 半分の解像度で2枚のRender Textureを生成(-2が、スクリーンサイズの半分(1/2)を意味する)
    // ふたつ取得しているのは、縦方向と横方向の2回、ブラー処理を分けて行うため
    buf.GetTemporaryRT(_blurredID1, -2, -2, 0, FilterMode.Bilinear);
    buf.GetTemporaryRT(_blurredID2, -2, -2, 0, FilterMode.Bilinear);

    // スクリーンサイズと同サイズのRTから半分のサイズのRTにコピーを行うことで、自動的に半分のサイズにしてくれる
    buf.Blit(_copyTexID, _blurredID1);

    // コピー後はいらないので破棄
    buf.ReleaseTemporaryRT(_copyTexID);

    // ブラーのためにテクセルをフェッチするオフセットを、スクリーンサイズで正規化する
    float x = _offset / Screen.width;
    float y = _offset / Screen.height;

    // ガウシアンブラー用の「重み」パラメータを設定する
    buf.SetGlobalFloatArray(_weightsID, _weights);

    // ブラーの全体的な強さのパラメータを設定する
    buf.SetGlobalFloat(_intencityID, Intencity);

    // 横方向のブラー
    buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0));
    buf.Blit(_blurredID1, _blurredID2, _material);

    // 縦方向のブラー
    buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0));
    buf.Blit(_blurredID2, _blurredID1, _material);

    // ブラーをかけた最終結果のテクスチャをグローバルテクスチャとして設定する
    buf.SetGlobalTexture(_grabBlurTextureID, _blurredID1);
}

抜粋したコードにはなにを行っているかをコメントしてあります。
処理の流れにの詳細についてはそちらをご覧ください。

大雑把に流れを説明すると、コマンドバッファを生成してブラー処理のためのコマンドを構築し、それをカメラに登録する、という流れになります。

コマンドバッファの構築についての大雑把な流れは、

  1. 現在レンダリング済みの内容をテンポラリなRenderTextureに等倍でコピーする
  2. (1)でコピーしたものをさらに半分のサイズにコピーする
  3. (2)の半分サイズのテクスチャに対し、横方向のブラーをかける
  4. (3)の横方向ブラーの画像に対し、さらに縦方向のブラーをかける
  5. (4)の最終結果を、グローバルなテクスチャとして登録する

という手順で最終的なブラーがかかった画像を生成しています。

実際に、Frame Debuggerを使うとこの過程を見ることができます。
f:id:edo_m18:20190403111126g:plain

BuildCommandBufferメソッドでこのあたりの処理を行っています。

このセットアップ部分を理解する上で重要な点が「この構築を行っている時点では実際に描画処理は行われていない」という点です。

前述のように、CPUからGPUへは「コマンドバッファ」と呼ばれるバッファに命令を積み込んで送る、と説明しました。
つまりここではその「命令群」をバッファに積み込んでいるだけなので、これが実際にGPUに届いて処理されるのは設定したイベントのタイミングとなります。

ちなみに「設定したイベントのタイミング」とは、カメラに設定するときに指定したイベントの種類のことです。
以下のところですね。

cam.AddCommandBuffer(_cameraEvent, buf);

_cameraEventは(今回のサンプルでは)デフォルトでCameraEvent.AfterImageEffectsが設定されています。
読んで分かる通り、イベントタイプはイメージエフェクトのあと、となります。

イベントの種類は、ドキュメントから画像を引用させていただくと以下のようなタイミングに処理を差し込むことができるようになっています。
https://docs.unity3d.com/ja/current/uploads/SL/CameraRenderFlowCmdBuffers.svg

緑の丸い点のところが差し込める位置ですね。
今回はこのうち、イメージエフェクトのあと、というタイミングで処理を行っているわけです。

ブラー処理用シェーダ

ちなみに、コマンドバッファで使用しているブラーのためのシェーダも載せておきます。
こちらのシェーダについては以前の記事(Unityでガウシアンブラーを実装する)を参照ください。

Shader "UI/BlurEffect"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            ZTest Off
            ZWrite Off
            Cull Back

            CGPROGRAM
            #pragma fragmentoption ARB_precision_hint_fastest
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

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

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

            sampler2D _MainTex;

            half4 _Offsets;
            float _Intencity;

            static const int samplingCount = 10;
            half _Weights[samplingCount];
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;

                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 0;

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

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

                return col;
            }
            ENDCG
        }
    }
}

uGUIをカスタムする

最後に、上記までで得られたブラー画像を利用してuGUIの背景を作る過程を書いていきます。
この「ブラーをかけた画像を利用する」ため、少しだけシェーダを書かないとなりません。

また通常のモデルとは異なり、uGUIのシェーダは専用の処理もあるため普通に生成したシェーダでそのまま書いてもうまく動きません。

このあたりはテラシュールブログさんの以下の記事を参考にさせていただきました。

tsubakit1.hateblo.jp

要点だけ書いておくと、Unityの公式サイトからビルトインシェーダをDLしてきて、その中でUI用のシェーダをベースにカスタムしていく、という感じです。

カスタム後のシェーダを見てみましょう。

カスタム後のシェーダコード

Shader "UI/BlurScreen"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)

        _StencilComp ("Stencil Comparison", Float) = 8
        _Stencil ("Stencil ID", Float) = 0
        _StencilOp ("Stencil Operation", Float) = 0
        _StencilWriteMask ("Stencil Write Mask", Float) = 255
        _StencilReadMask ("Stencil Read Mask", Float) = 255

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
        }

        Stencil
        {
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        }

        Cull Off
        Lighting Off
        ZWrite Off
        ZTest [unity_GUIZTestMode]
        Blend SrcAlpha OneMinusSrcAlpha
        ColorMask [_ColorMask]

        Pass
        {
            Name "Default"
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0

            #include "UnityCG.cginc"
            #include "UnityUI.cginc"

            #pragma multi_compile __ UNITY_UI_CLIP_RECT
            #pragma multi_compile __ UNITY_UI_ALPHACLIP

            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                float2 texcoord  : TEXCOORD0;
                float4 worldPosition : TEXCOORD1;
                float4 pos : TEXCOORD2;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            sampler2D _MainTex;
            fixed4 _Color;
            fixed4 _TextureSampleAdd;
            float4 _ClipRect;
            float4 _MainTex_ST;
            sampler2D _GrabBlurTexture;

            v2f vert(appdata_t v)
            {
                v2f OUT;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
                OUT.worldPosition = v.vertex;
                OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);

                OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);

                OUT.pos = ComputeScreenPos(OUT.vertex);

                OUT.color = v.color * _Color;
                return OUT;
            }

            fixed4 frag(v2f IN) : SV_Target
            {
                float2 uv = IN.pos.xy / IN.pos.w;
                uv.y = 1.0 - uv.y;

                half4 color = (tex2D(_GrabBlurTexture, uv) + _TextureSampleAdd) * IN.color;

                #ifdef UNITY_UI_CLIP_RECT
                color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
                #endif

                #ifdef UNITY_UI_ALPHACLIP
                clip (color.a - 0.001);
                #endif

                half4 mask = tex2D(_MainTex, IN.texcoord);
                color.a *= mask.a;

                return color;
            }
        ENDCG
        }
    }
}

色々記載がありますが、修正した箇所はそれほど多くありません。(ほぼ、UnityのビルトインシェーダをDLしてきたコードそのままです)

編集した部分だけを抜粋すると以下の箇所になります。

struct v2f
{
    /* ... 略 ... */
    float4 pos : TEXCOORD2; // キャプチャした画像のテクセルをフェッチするための変数
    /* ... 略 ... */
};

/* ... 略 ... */

// CommandBufferでキャプチャした画像が渡されてくる
sampler2D _GrabBlurTexture;

v2f vert(appdata_t v)
{
    /* ... 略 ... */

    // uGUIのメッシュの位置をスクリーン位置に変換する
    OUT.worldPosition = v.vertex;
    OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
    OUT.pos = ComputeScreenPos(OUT.vertex);

    /* ... 略 ... */
}

fixed4 frag(v2f IN) : SV_Target
{
    // デバイス正規化座標系とするため`w`で除算する
    // が、ComputeScreenPosの段階で正常な値が入っているっぽいが、
    // シーンビューでちらつくのでこうしておく
    float2 uv = IN.pos.xy / IN.pos.w;

    // キャプチャ時に反転しているのでUVを反転してフェッチするようにする
    uv.y = 1.0 - uv.y;
    half4 color = (tex2D(_GrabBlurTexture, uv) + _TextureSampleAdd) * IN.color;

    /* ... 略 ... */

    // uGUIのImageに設定されたテクスチャをマスク画像として利用する
    // 今回の例ではアルファ値でマスクしているが、白黒画像やその他の画像で独自にマスク位置を変更したい場合はここをいじる
    half4 mask = tex2D(_MainTex, IN.texcoord);
    color.a *= mask.a;

    return color;
}

追記した部分だけを抜き出しました。
見てもらうと分かる通り、それほど多くのコードは追加していないのが分かると思います。

どういう処理なのかはコメントとして追記したのでそちらをご覧ください。

大まかに説明だけすると、CommandBufferによって得られたブラー画像がuGUIの位置から見てどの位置なのか、の計算を行い、そのUV値を元にブラー画像からテクセルをフェッチし、それを利用しています。

またImageに設定されたテクスチャはマスク画像として利用するようにしているので特定の形にくり抜くことができます。

ここで重要な点は、実際に「半透明になっているわけではなく」、あくまでキャプチャした画像の適切な位置を利用することで、あたかも透過しているように見える、というわけです。

最後に

今回のサンプルは自分のiPhoneXでも問題なく動きました。処理負荷的にもそこまで大きくはないかなと思います。

iOSの表現でもブラー処理はよく見ますね。すりガラス風の表現はオシャレに見えるので、ワンポイントのアクセントなどに利用すると質感があがっていい感じです。

今回はuGUIの背景として利用しましたが、すりガラス風の表現はこれ以外にも活用する部分はあると思います。色々試してみるといいかもしれません。

Scriptable Render Pipeline(SRP)についてちょっと調べてみた

概要

いつもお世話になっている凹みさんの記事を参考にさせてもらってます。
基本的には凹みさんの記事を見ながら、自分の理解やメモを書いているだけの記事となります。

tips.hecomi.com

なお、SRPについてはUnityのGithubアカウントから提供されているものをクローンして利用します。

github.com

インストールする

まず、上記リポジトリからSRPのプラグインをクローンします。

利用方法についてはちょっとだけ複雑で、GithubのReadmeには以下のように書かれています。

How to use the latest version

Note: The Master branch is our current development branch and may not work on the latest publicly available version of Unity. To determine which version of SRP you should use with your version of Unity, go to Package Manager (Window > Package Manager > Show Preview Packages) to see what versions of SRP are available for your version of Unity Editor. Then you can search the Tags tab of the Branch dropdown in the SRP GitHub for that tag number. To use the latest version of the SRP, follow the instructions below:


This repository consists of a folder that should be cloned outside the Assets\ folder of your Unity project. We recommend creating a new project to test SRP. Do not clone this repo into an existing project unless you want to break it, or unless you are updating to a newer version of the SRP repo.


After cloning you will need to edit your project's packages.json file (in either UnityPackageManager/ or Packages/) to point to the SRP submodules you wish to use. See: https://github.com/Unity-Technologies/ScriptableRenderPipeline/blob/master/TestProjects/HDRP_Tests/Packages/manifest.json


This will link your project to the specific version of SRP you have cloned.


You can use the GitHub desktop app to clone the latest version of the SRP repo or you can use GitHub console commands.

ざっくりまとめると、

  1. パッケージマネージャを開き、プレビュー版も表示する(Window > Package Managerを開き、表示されたWindow上部にあるAdvancedをクリックしてShow Preview Packagesを選択)
  2. そこで表示されるSRPの利用可能バージョンを見る(これはUnityのバージョンによって異なるみたい)(※1)
  3. 利用可能バージョンと同じバージョンが記載されているタグ(Gitのタグ)をチェックアウト
  4. チェックアウトしたものを、Assetsフォルダと同階層(かそれより上)に配置する(Assets内には入れない)
  5. Package ManagerのJSONファイルに依存を追記する(※2)

※1 ... 開くとこんな感じのWindowが開き、確認することが出来ます。 f:id:edo_m18:20190307123332p:plain

※2 ... 依存関係についてはサンプルのJSONが公開されているので参照してください。 ちなみに、サンプルでは"com.unity.render-pipelines.core": "file:../../../com.unity.render-pipelines.core",みたいに書かれていますが、これは適切に、自分で配置したフォルダへの参照となるよう修正が必要です。

Lightweight Pipelineをインストールする

今回は、VR/AR向けに調べてたこともあってLightweight Pipelineのみの説明ですが、インストール自体はLightweight Pipelineの話ですが、High Definition Render Pipelineも基本的には同じです。

SRP自体をインストールしてあれば、同リポジトリに、LWRP用のモジュールも含まれているので、Package ManagerのJSONに依存関係を記載しておけば自動的にインポートされます。

LWRPを利用する

以上でインストールが完了しました。
次に行うのは、実際のレンダリングが、指定のLWRPで実行されるようにセットアップすることです。

Readmeから引用すると以下のように記述されています。

To create a Render Pipeline Asset:


In the Project window, navigate to a directory outside of the Scriptable Render Pipeline Folder, then right click in the Project window and select Create > Render Pipeline > High Definition or Lightweight > Render Pipeline/Pipeline Asset. Navigate to Edit > Project Settings > Graphics and add the Render Pipeline Asset you created to the Render Pipeline Settings field to use it in your project.

SRPが正常にインストールされていると、Project WindowのCreateメニューにSRP用のメニューが追加されています。

f:id:edo_m18:20190307124330p:plain

上記のように、メニューから「Lightweight Render Pipeline Asset」を生成します。
そしてEdit > Project Settings > Graphicsから開くグラフィクスセッティングに、上で生成したAssetを設定します。

f:id:edo_m18:20190307143812p:plain

これでLWRPを利用したレンダリングが行われるようになります。
ただこれを設定するとStandardシェーダが使えなくなるので、LWRP専用のシェーダを利用する必要があります。

LWRP専用シェーダについては別の記事で詳細を書きたいと思います。(というか、まだ現在調査中)

独自のSRPを実装する

LWRPだけ見ていても仕組みは理解できないので、独自のSRPを作って仕組みを把握してみたいと思います。

と言っても、今回は「なにもしない/簡単な処理だけ」のSRPを実装することでSRPがどういうことをやってくれるのかを把握するにとどめます。(というか、まだなにができるかを正確に把握していないので)

「なにもしない」SRP

まず見てみるのは、「なにもしない」SRPの実装です。
やることはただひとつ、グリーンで塗りつぶすだけです。

コードを見てみましょう。

なお、コードはこちらのリポジトリを参考にさせていただいています。
(こちらは公式ブログのデモのようです)

github.com

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;

public class BasicAssetPipe : RenderPipelineAsset
{
    public Color ClearColor = Color.green;

#if UNITY_EDITOR
    [UnityEditor.MenuItem("SRP-Demo/01 - Create Basic Asset Pipeline")]
    static void CraeteBasicAssetPipeline()
    {
        var instance = ScriptableObject.CreateInstance<BasicAssetPipe>();
        UnityEditor.AssetDatabase.CreateAsset(instance, "Assets/SRP-Demo/1-BasicAssetPipe.asset");
    }
#endif

    protected override IRenderPipeline InternalCreatePipeline()
    {
        return new BasicPipeInstance(ClearColor);
    }
}

public class BasicPipeInstance : RenderPipeline
{
    private Color _clearColor = Color.black;

    public BasicPipeInstance(Color clearColor)
    {
        _clearColor = clearColor;
    }

    public override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        // does not so much yet.
        base.Render(context, cameras);

        // Clear buffer to the configured color.
        var cmd = new CommandBuffer();
        cmd.ClearRenderTarget(true, true, _clearColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Release();
        context.Submit();
    }
}

前述のように、SRPを利用するには、そのパイプラインを記述したAssetを生成してそれをセットすることで実現します。
なので、冒頭ではアセットを生成するEditor向けのクラスが定義されていますね。(↓これ)

public class BasicAssetPipe : RenderPipelineAsset
{
    public Color ClearColor = Color.green;

#if UNITY_EDITOR
    [UnityEditor.MenuItem("SRP-Demo/01 - Create Basic Asset Pipeline")]
    static void CraeteBasicAssetPipeline()
    {
        var instance = ScriptableObject.CreateInstance<BasicAssetPipe>();
        UnityEditor.AssetDatabase.CreateAsset(instance, "Assets/SRP-Demo/1-BasicAssetPipe.asset");
    }
#endif

    protected override IRenderPipeline InternalCreatePipeline()
    {
        return new BasicPipeInstance(ClearColor);
    }
}

RenderPipelineAssetクラスを継承したアセット生成を行う

RenderPipelineAssetクラスを継承した、独自パイプラインのためのクラスを定義します。
RenderPipelineAssetScriptableObjectを継承しているので、ScriptableObject化してアセットとして保存できるようになっています。

また、RenderPipelineAssetクラスはabstractクラスになっていて、以下のメソッドのみ、オーバーライドする必要があります。

//
// Summary:
//     ///
//     Create a IRenderPipeline specific to this asset.
//     ///
//
// Returns:
//     ///
//     Created pipeline.
//     ///
protected abstract IRenderPipeline InternalCreatePipeline();

中身は、IRenderPipelineを実装したクラスを作れ、ということのようです。

インターフェースはシンプルで、以下のふたつのメソッドを実装するのみとなっています。

//
// Summary:
//     ///
//     When the IRenderPipeline is invalid or destroyed this returns true.
//     ///
bool disposed { get; }

//
// Summary:
//     ///
//     Defines custom rendering for this RenderPipeline.
//     ///
//
// Parameters:
//   renderContext:
//     Structure that holds the rendering commands for this loop.
//
//   cameras:
//     Cameras to render.
void Render(ScriptableRenderContext renderContext, Camera[] cameras);

重要なのはRenderメソッドでしょう。ここで、実際のレンダリングの処理を行うわけです。

参考に上げた実装を見てみると以下のように実装されています。

protected override IRenderPipeline InternalCreatePipeline()
{
    return new BasicPipeInstance(ClearColor);
}

BasicPipeInstanceを、ClearColorを引数に生成したものを返しているだけですね。

IRenderPipelineインターフェースを実装したクラスを作る

ではBasicPipeInstanceのほうの実装も見てみましょう。

public class BasicPipeInstance : RenderPipeline
{
    private Color _clearColor = Color.black;

    public BasicPipeInstance(Color clearColor)
    {
        _clearColor = clearColor;
    }

    public override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        // does not so much yet.
        base.Render(context, cameras);

        // Clear buffer to the configured color.
        var cmd = new CommandBuffer();
        cmd.ClearRenderTarget(true, true, _clearColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Release();
        context.Submit();
    }
}

ベースクラスとしてRenderPipelineを継承していますが、RenderPipelineクラスはabstractクラスになっていて、インターフェースに必要な機能の定義と、レンダリング時のイベントをフィールドとして持つようになっているだけのクラスとなっています。

さて、BasicPipeInstanceで重要なのはRenderメソッドですね。
ここを見ればSRPで最低限なにをしないとならないかが分かるはずです。

そこだけを抜き出して見てみましょう。

base.Render(context, cameras);

// Clear buffer to the configured color.
var cmd = new CommandBuffer();
cmd.ClearRenderTarget(true, true, _clearColor);
context.ExecuteCommandBuffer(cmd);
cmd.Release();
context.Submit();

とてもシンプルですね。
コマンドバッファを作り、レンダーターゲットを指定した色(今回の例だとグリーン)でクリアし、それを実行しているだけ、と。

これで画面がグリーンのベタ塗りで表示される、というわけですね。
つまり、コマンドバッファを作ってそこで必要な処理をする、ということのようです。

ちなみに、サンプルではレンダリングごとにCommandBufferを生成していますが、コンストラクタで生成して使いまわしても大丈夫なようです。

不透明オブジェクトだけをレンダリングするSRP

さて、前述のSRPでは背景をベタ塗りするだけのものでした。
そのため、シーン内にオブジェクトがあっても表示されていませんでした。

次は不透明オブジェクトだけをレンダリングするSRPの実装を見てみましょう。
これを見ることで、なんとなくパイプライン全体の流れが把握できるかと思います。

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

public class OpaqueAssetPipe : RenderPipelineAsset
{
#if UNITY_EDITOR
    [UnityEditor.MenuItem("SRP-Demo/02 - Create Opaque Asset Pipeline")]
    static void CreateOpaqueAssetPipeline()
    {
        var instance = ScriptableObject.CreateInstance<OpaqueAssetPipe>();
        UnityEditor.AssetDatabase.CreateAsset(instance, "Assets/SRP-Demo/2-OpaqueAssetPipe.Asset");
    }
#endif

    protected override IRenderPipeline InternalCreatePipeline()
    {
        return new OpaquePipeInstance();
    }
}

public class OpaquePipeInstance : RenderPipeline
{
    public override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        base.Render(context, cameras);

        foreach (var camera in cameras)
        {
            // Culling
            ScriptableCullingParameters cullingParams;
            if (!CullResults.GetCullingParameters(camera, out cullingParams))
            {
                continue;
            }

            CullResults cull = CullResults.Cull(ref cullingParams, context);

            // Setup camera for rendering (sets render target, view/projection matrices and other
            // per-camera built-in shader variables).
            context.SetupCameraProperties(camera);

            // clear depth buffer
            var cmd = new CommandBuffer();
            cmd.ClearRenderTarget(true, false, Color.black);
            context.ExecuteCommandBuffer(cmd);
            cmd.Release();

            // Draw opaque objects using BasicPass shader pass
            DrawRendererSettings settings = new DrawRendererSettings(camera, new ShaderPassName("BasicPass"));
            settings.sorting.flags = SortFlags.CommonOpaque;

            FilterRenderersSettings filterSettings = new FilterRenderersSettings(true)
            {
                renderQueueRange = RenderQueueRange.opaque
            };

            context.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);

            // Draw skybox
            context.DrawSkybox(camera);

            context.Submit();
        }
    }
}

さて、最初のやつよりも少しコードが長くなりましたね。
しかしやっていることは比較的シンプルです。

冒頭ではカリングに関する処理を行っています。

// Culling
ScriptableCullingParameters cullingParams;
if (!CullResults.GetCullingParameters(camera, out cullingParams))
{
    continue;
}

CullResults cull = CullResults.Cull(ref cullingParams, context);

CullResutls.GetCullingParametersによってカメラの状態を評価します。
ドキュメントを見ると以下のように書かれています。

Get culling parameters for a camera.


Returns false if camera is invalid to render (empty viewport rectangle, invalid clip plane setup etc.).

つまり、viwportがemptyだったり、など不正な状態の場合は処理しないようになっています。

そして続くCullResults.Cull(...);によってカリングなどを評価し、実際に表示されるオブジェクトなどの結果を得ます。

ドキュメントによると以下のような結果を得ます。

Culling results (visible objects, lights, reflection probes).

さて、次に行うのはカメラに対するプロパティの設定です。

// Setup camera for rendering (sets render target, view/projection matrices and other
// per-camera built-in shader variables).
context.SetupCameraProperties(camera);

コメントにもあるように、様々な値を設定するようです。
ドキュメントにも以下のように書かれています。

Setup camera specific global shader variables.


This function sets up view, projection and clipping planes global shader variables.


Additionally, if stereoSetup is set to true, and single-pass stereo is enabled, stereo-specific shader variables and state are configured.

プロジェクション行列やクリッピングプレーンの情報など、カメラに必要な設定を行うようですね。

実際にレンダリングを行うコマンドを構築する

以上で設定などの処理が終了しました。
最後に行うのは実際にレンダリングを行うためのコマンドバッファの構築です。

やや長めですが、ざーっと見てみましょう。

// clear depth buffer
var cmd = new CommandBuffer();
cmd.ClearRenderTarget(true, false, Color.black);
context.ExecuteCommandBuffer(cmd);
cmd.Release();

// Draw opaque objects using BasicPass shader pass
DrawRendererSettings settings = new DrawRendererSettings(camera, new ShaderPassName("BasicPass"));
settings.sorting.flags = SortFlags.CommonOpaque;

FilterRenderersSettings filterSettings = new FilterRenderersSettings(true)
{
    renderQueueRange = RenderQueueRange.opaque
};

context.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);

// Draw skybox
context.DrawSkybox(camera);

context.Submit();

まず冒頭で行っているのは、レンダーターゲットのクリアですね。
このあたりは「グリーンベタ塗り」のときとあまり違いはありません。

その後に行っているのは、レンダリングする対象の絞り込みとその設定です。

レンダリング対象を指定する

DrawRendererSettingsによってどのキューのオブジェクトをレンダリング対象とするかを決め、またどのパス名のシェーダを利用するかも指定しています。

// Draw opaque objects using BasicPass shader pass
DrawRendererSettings settings = new DrawRendererSettings(camera, new ShaderPassName("BasicPass"));
settings.sorting.flags = SortFlags.CommonOpaque;

new ShaderPassName("BasicPass")によってレンダリングするパス名を指定しているのが分かります。今回の例ではBasicPassと名前がついたパスをレンダリング対象としています。

ちょっとシェーダ側の記述を見てみましょう。

Shader "Sample/BasicPass"
{
    Properties { ... }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        Pass
        {
            Tags { "LightMode" = "BasicPass" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata { ... };

            struct v2f { ... };

            v2f vert (appdata v) { ... }

            fixed4 frag (v2f i) : SV_Target { ... }
            ENDCG
        }
    }
}

細かい処理を除いたサンプルです。
基本的な処理は違いはありません。

違いがあるのは、LightModeBasicPassと指定されている点にあります。
これは、前述のパイプラインのところで書かれていたnew ShaderPassName("BasicPass")と同じ名前ですね。

つまり、前述のパイプラインではこのパスのみをレンダリングしていた、というわけなんですね。

続く指定処理を見ていきましょう。

FilterRenderersSettings filterSettings = new FilterRenderersSettings(true)
{
    renderQueueRange = RenderQueueRange.opaque
};

context.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);

// Draw skybox
context.DrawSkybox(camera);

context.Submit();

最後はざーっと見てしまいましょう。

次に行っているのはフィルタリング(FilterRenderersSettings)ですね。

引数は初期化に関するパラメータのようです。ドキュメントには以下のように書かれています。

If initializeValues is true all values are initialized such that no filtering will occur. Otherwise the values are default initialized.

正直ここの変更でなにが変わるのかはまだちょっとよく分かっていません。

が、falseにしたら画面にオブジェクトが表示されなくなったので、基本はtrueでいいのかな、と思います。

さぁ、これで準備が整いました。
あとは実際にレンダリングの処理を記述すれば完成です。

レンダリングにはcontext.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);を呼び出します。

引数を見ればなんとなく推測できますが、CullResultsによって得られたレンダリング対象と、フィルタ設定を引数にコンテキストのDrawRenderersを呼び出します。

これが、対象オブジェクトのレンダリングを行っている箇所ですね。

そして最後に、context.DrawSkybox(camera);によってスカイボックスが描画されます。
スカイボックスは大抵最後に行われるのでこの位置なのでしょう。

あとはこれをSubmitすれば構築が完了します。

最後に

ベタ塗りだけするSRPと、不透明オブジェクトだけをレンダリングするSRPを見てきました。
どちらも非常にシンプルですが、SRPがどういうことをしてくれる機能なのか、そしてどんなことができるのかはこれでなんとなく見えてきたかと思います。

実際、使用に耐えうるものを構築するにはもっと色々な知識(SRPだけの話ではなく、レンダリングパイプライン全体の話)が必要になるので、イチから構築するというのはあまり現実的ではないかもしれません。

しかし、LWRPを使う際や、既存の仕組みをカスタムする際にはこうした知識は役に立つと思います。

次はLWRPを触ってみて、どんなことができるのか書いてみたいと思います。

UnityのARKit Pluginのカメラ映像を利用してなにかする

概要

UnityのARKit Pluginを使えばARコンテンツを手軽に作ることができます。
しかし、当然ですがARは外界をカメラで撮影し、それを元に姿勢を判断しています。

つまり、QRコードリーダーやOpenCVなどを利用した画像認識など「カメラを利用した処理」をするには、ARKit Pluginで利用しているカメラ映像を利用しないとなりません。

個別にカメラを起動して、その映像を使う、ということができないからです。

今回はARKit Pluginのカメラの映像を利用して画像処理をするためのTipsをまとめておこうと思います。

大まかな流れ

主な処理はUnityARVideoのコードを参考にしました。

  1. ARKitのセッションからARTextureHandlesを取得する
  2. Texture2D.CreateExternalTextureメソッドを利用して、ネイティブテクスチャのポインタからテクスチャを生成する
  3. UpdateExternalTextureメソッドを利用してテクスチャの内容をアップデートする
  4. UnityのARKit Pluginが提供してくれているマテリアルを利用して、ふたつのテクスチャを合成する(カメラの映像として見れる形に復元する(YCbCrフォーマットでふたつのテクスチャとして取得するため))
  5. RenderTextureの内容をTexture2Dにコピーする

という流れになります。

以下、細かく見ていきましょう。

ARTextureHandlesを取得する

UnityARSessionNativeInterfaceGetARVideoTextureHandlesというメソッドを利用してARTextureHandlesという、テクスチャのハンドルを取得することができます。

このハンドルを利用して、ARKit側で生成したネイティブのテクスチャへ(ポインタを経由して)アクセスすることができます。

ネイティブで生成されたテクスチャからテクスチャの内容を取得するには以下のようにします。

ARTextureHandles handles = UnityARSessionNativeInterface.GetARSessionNativeInterface().GetARVideoTextureHandles();
if (handles.IsNull())
{
    return;
}

Resolution currentResolution = Screen.currentResolution;

// _textureYはTexture2D
if (_textureY == null)
{
    _textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
    _textureY.filterMode = FilterMode.Bilinear;
    _textureY.wrapMode = TextureWrapMode.Repeat;
    _yuvMat.SetTexture("_textureY", _textureY);
}

handles.TextureYはネイティブテクスチャへのポインタとなっていてSystem.IntPtr型です。
そしてTexture2Dにはこうしたネイティブテクスチャからテクスチャを生成することができるようになっています。

それが以下の部分です。

_textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);

CreateExternalTextureでネイティブテクスチャを元にテクスチャを生成することができます。
そしてテクスチャの内容を実際に取得して更新するにはUpdateExternalTextureメソッドを使います。

_textureY.UpdateExternalTexture(handles.TextureY);

とすることで、生成したTexture2Dの内容をアップデートすることができます。

ふたつのテクスチャを合成する

無事、ネイティブテクスチャからふたつのテクスチャを得ることができました。
ただ、前述のように、これらのテクスチャはカメラの映像がそのまま、というわけではありません。

YCbCrというフォーマットになっていて、適切に合成しないと元のカメラの映像になりません。
Wikipediaから引用させてもらうと以下の意味のようです。

YUVやYCbCrやYPbPrとは、輝度信号Yと、2つの色差信号を使って表現される色空間。

Wikipediaから画像を引用させてもらうと、以下のような感じのテクスチャが得られます。

YCbCr画像イメージ

一番上が元画像、その下が輝度画像、そしてその下がそれぞれ2つの色差信号によって表現されたものです。
これを合成して元の形に復元するために、ARKitPluginが提供してくれている「YUVMaterial」を利用します。

前段でネイティブテクスチャの情報は取得しているので、あとはこれを合成するマテリアルを通してRenderTextureに描き出してやればOKです。

// ネイティブテクスチャからテクスチャを生成し、マテリアルにセットしているところ
_yuvMat.SetTexture("_textureY", _textureY);

private void OnPostRender()
{
    // ... 中略 ...

    // RenderTextureへ、マテリアルの内容を書き込み
    Graphics.Blit(null, _arTexture, _yuvMat);

    // RenderTextureの内容をTexture2Dにコピーするため、AsyncGPUReadbackを利用して読み出し
    _request = AsyncGPUReadback.Request(_arTexture);
}

Graphics.Blitを利用してRenderTextureにマテリアルの内容を書き出します。
最終的に利用する形がRenderTextureなのであれば以上で終了です。
が、大体の場合はTexture2Dにするなり、テクセルの配列を利用して処理するなりの「情報として扱える形」に変換する必要が出てくるでしょう。

RenderTextureの内容をTexture2Dにコピーする

最後の工程は、テクスチャの合成を施した結果であるRenderTextureの内容をTexture2Dにコピーすることです。

コピーは以下のようにします。

RenderTexture back = RenderTexture.active;
RenderTexture.active = _arTexture;
_arTexture2D.ReadPixels(new Rect(0, 0, _arTexture.width, _arTexture.height), 0, 0);
_arTexture2D.Apply();
RenderTexture.active = back;

しかしこれ、実はだいぶ重い処理になります。 おそらくCPUを利用して全テクセルを読み出していると思うので、時間がかかります。
特に問題なのはメインスレッドで実行されるため、UIを停止、あるいはFPSの低下を招きます。

なので数フレームに1度、などの最適化を行わないとならないかもしれません。
幸いにして、今回やりたかったのはARKitのカメラの映像を利用してQRコードの読み取りをする、というものです。

なのでQRコードの読み取りが必要なタイミングでだけ有効にすることで今回は回避しています。

余談:AsyncGPUReadbackを使ってRenderTexutreの内容を読み出す

上で説明したように、ReadPIxelsはとても重い処理です。
そこでAsyncGPUReadbackというメソッドが追加されました。

これはGPURenderTextureの読み取りを実行し、結果を非同期で返してくれるメソッドです。

使い方などについては以下の記事が詳しく解説してくれているので参考にしてみてください。

qiita.com

なお、ここで紹介されている_tex.LoadRawTextureData(buffer);という形で読み込むと正常にデータが読み込めず、おかしな表示になってしまっていました。

keijiroさんのこちらのサンプルを見ると_tex.SetPixels32(buffer.ToArray());という形で読み込んでいて、こちらを試したところ正常に表示されました。

github.com

さて、最後に実際に実装したコードを掲載しておきます。

コード全文

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
using UnityEngine.Rendering;
using UnityEngine.XR.iOS;

namespace ARKitTextureSample
{
    public delegate void OnReadQRCode(string url);

    public class ARQRReader : MonoBehaviour
    {
        public event OnReadQRCode OnReadQRCode;

        [SerializeField]
        private RawImage _preview = null;

        [SerializeField]
        private Text _text = null;

        [SerializeField]
        private Material _yuvMat = null;

        private string _result = null;

        private Matrix4x4 _displayTransform;
        private Texture2D _textureY = null;
        private Texture2D _textureCbCr = null;
        private RenderTexture _arTexture = null;
        private Texture2D _arTexture2D = null;
        private bool _isActive = false;

#if !UNITY_EDITOR && UNITY_IOS
        private void Start()
        {
            _arTexture = new RenderTexture(Screen.width, Screen.height, 0);
            _arTexture2D = new Texture2D(_arTexture.width, _arTexture.height, TextureFormat.ARGB32, false);
            _yuvMat = Instantiate(_yuvMat);
        }

        public void Active(bool active)
        {
            if (_isActive == active)
            {
                return;
            }

            if (active)
            {
                UnityARSessionNativeInterface.ARFrameUpdatedEvent += UpdateFrame;
            }
            else
            {
                UnityARSessionNativeInterface.ARFrameUpdatedEvent -= UpdateFrame;
            }

            _isActive = active;
        }

        private void UpdateFrame(UnityARCamera cam)
        {
            _displayTransform = new Matrix4x4();
            _displayTransform.SetColumn(0, cam.displayTransform.column0);
            _displayTransform.SetColumn(1, cam.displayTransform.column1);
            _displayTransform.SetColumn(2, cam.displayTransform.column2);
            _displayTransform.SetColumn(3, cam.displayTransform.column3);
        }

        public void OnPreRender()
        {
            if (!_isActive)
            {
                return;
            }

            ARTextureHandles handles = UnityARSessionNativeInterface.GetARSessionNativeInterface().GetARVideoTextureHandles();
            if (handles.IsNull())
            {
                return;
            }

            Resolution currentResolution = Screen.currentResolution;

            if (_textureY == null)
            {
                _textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
                _textureY.filterMode = FilterMode.Bilinear;
                _textureY.wrapMode = TextureWrapMode.Repeat;
                _yuvMat.SetTexture("_textureY", _textureY);
            }

            if (_textureCbCr == null)
            {
                _textureCbCr = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
                _textureCbCr.filterMode = FilterMode.Bilinear;
                _textureCbCr.wrapMode = TextureWrapMode.Repeat;
                _yuvMat.SetTexture("_textureCbCr", _textureCbCr);
            }

            _textureY.UpdateExternalTexture(handles.TextureY);
            _textureCbCr.UpdateExternalTexture(handles.TextureCbCr);

            _yuvMat.SetMatrix("_DisplayTransform", _displayTransform);
        }

        private void OnPostRender()
        {
            if (!_isActive)
            {
                return;
            }

            if (_textureY == null || _textureCbCr == null)
            {
                return;
            }

            Graphics.Blit(null, _arTexture, _yuvMat);

            RenderTexture back = RenderTexture.active;
            RenderTexture.active = _arTexture;
            _arTexture2D.ReadPixels(new Rect(0, 0, _arTexture.width, _arTexture.height), 0, 0);
            _arTexture2D.Apply();
            RenderTexture.active = back;

            _preview.texture = _arTexture2D;

            _result = QRCodeHelper.Read(_arTexture2D);

            if (_result != "error")
            {
                _text.text = _result;
                OnReadQRCode?.Invoke(_result);
            }
        }
#else
        public void Active(bool active)
        {
            _isActive = active;
        }
#endif
    }
}

まとめ

今回はARKitのカメラからの映像を利用して色々してみるという趣旨でしたが、ネイティブテクスチャからのテクスチャ生成および更新など、知らない機能についても知れたのでよかったです。

またそれ以外にも、(それなりに)高速にRenderTextureからTexture2Dへ内容をコピーするAsyncGPUReadback活用の幅が広そうです。

今後、ディープラーニングなど「画像解析」を経てなにかを行うことは増えていきそうなので、このあたりの処理は覚えておくとよさそうです。

特定のUIを除いて画面のスクリーンショットを撮影する

概要

ゲーム画面のスクリーンショットを撮影したい、というのはよくある要望でしょう。
そしてさらに「特定のUIだけを除いて」撮影したい、というのもわりとある要望だと思います。
例えば操作用のUIは非表示だけども、ステータス表示などは表示しておきたい、などですね。

今回はそれを実現する方法について書きたいと思います。

なお、今回の記事の動作サンプルはGithubにアップしてあります。

github.com

実際に動かした動画↓

大まかなフロー

まず詳細を説明する前に、必要な項目について簡単に説明しておきます。

  1. GUI用のカメラを用意する
  2. 無視したいUIを持っているCanvasのレイヤーを適切に設定する
  3. CommandBufferを用いて、UI以外の要素がレンダリングされたあとのタイミングでバッファをコピーする
  4. GUI描画時に、カメラのレンダリングするレイヤー(cullingMask)を変更する
  5. GUI用カメラを、(3)で取得したテクスチャに追加で描画する

という流れになります。

詳細については順に説明していきます。

GUI用カメラを用意する

Canvas要素はデフォルトではRender ModeScreen Space - Overlayになっています。
これをまずScreen Space - Cameraに変更します。

すると以下のように、GUI要素をレンダリングするためのカメラを設定する項目が表示されるのでGUI用に用意したカメラを設定します。

f:id:edo_m18:20190208200618p:plain

なお、GUI用に用意したカメラではClear FlagsDepth Onlyに変更しておきます。

f:id:edo_m18:20190208200730p:plain

これでカメラ側の準備はOKです。

Canvasにレイヤーを設定する

続いて、Layerを設定していきます。
Cameraにはレイヤーごとにレンダリングするかしないか、というマスクが設定できるようになっており、これを利用してキャプチャ時に描画対象とするか、を切り替えます。

なので、まずは無視するUIのレイヤーを設定できるように新しいレイヤーを追加します。
(今回のサンプルではMenuUIという名前にしました)

そして新しいレイヤーが追加できたら、キャプチャ時に無視したいUI Canvasにそのレイヤーを設定します。

f:id:edo_m18:20190208211428p:plain

なお、レイヤーマスクの設定で無視されるかどうかはCanvasのレイヤー設定のみが反映されるようです。
なので、UI要素に対して個別にレイヤーを設定してもレンダリングされてしまいます。

もし細かく制御したい場合は、Canvas入れ子にして、そのCanvasにレイヤーを設定し、さらにそのCanvasの小要素としてキャプチャされたくないUI要素を入れることで対応することができます。

CommandBufferを用いてシーンの状況をキャプチャする

今回はシーンのキャプチャにCommandBufferを用いることにしました。
(もちろん、それ以外の方法でもキャプチャできます)

なお、CommandBuffer自体の詳細については凹みさんの記事がとても分かりやすく書かれているのでオススメです。

tips.hecomi.com

CommandBufferをカメラに登録する

CommandBufferは、カメラのレンダリングのパイプラインの中で、特定のイベントに紐づけて呼び出される「コマンド」を追加できる仕組みです。

今回はすべてのオブジェクトがレンダリングされ終わったタイミングでキャプチャしたかったのでCameraEvent.BeforeImageEffectsというタイミングでキャプチャを行っています。

コマンドバッファの生成は以下のようにしています。

/// <summary>
/// バッファを生成する
/// </summary>
private void CreateBuffer()
{
    _buf = new RenderTexture(Screen.width, Screen.height, 0);

    _commandBuffer = new CommandBuffer();
    _commandBuffer.name = "CaptureScene";
    _commandBuffer.Blit(BuiltinRenderTextureType.CurrentActive, _buf);
}

行っていることは単純に、生成したRenderTextureに、現在の(CurrentActive)状態をコピーしているだけです。

あとは、スクリーンショットを撮影するタイミングでこれをカメラに追加してやれば、適切なタイミングで処理が実行されます。

実際に実行すると以下のような形になります。

private void Update()
{
    if (Input.GetKeyDown(KeyCode.S))
    {
        TakeScreenshot();
    }
}

/// <summary>
/// スクリーンショットを撮影する
/// </summary>
public void TakeScreenshot()
{
    Camera.main.AddCommandBuffer(_cameraEvent, _commandBuffer);

    StartCoroutine(WaitCapture());
}

/// <summary>
/// コマンドバッファの処理を待つ
/// </summary>
private IEnumerator WaitCapture()
{
    yield return new WaitForEndOfFrame();

    BlendGUI();

    Camera.main.RemoveCommandBuffer(_cameraEvent, _commandBuffer);
}

スクリーンショットを撮影したいタイミングでコマンドバッファをカメラに追加し、そのフレームの終わりまで待機した上でコマンドバッファを取り除いています。

ここで取り除いているのは、取り除かないと以後常にコマンドバッファが呼ばれてしまうため一度だけシーンをコピーしたら呼び出されないようにしています。

ここまでで、GUIを除く部分がRenderTextureに描かれている状態となります。
あとはGUI部分を適切に設定して描画してやれば目的の絵を得ることができます。

描画対象のGUIだけを上乗せする

最後は、必要なGUI部分を描くことができれば目的達成です。
GUI部分を描画するには以下のようにします。

/// <summary>
/// GUI要素をブレンドする
/// </summary>
private void BlendGUI()
{
    _guiCamera.targetTexture = _buf;

    int tmp = _guiCamera.cullingMask;
    _guiCamera.cullingMask = _captureTargetLayer;

    _guiCamera.Render();

    _guiCamera.cullingMask = tmp;

    _guiCamera.targetTexture = null;
}

上記でやっていることは、GUI用カメラのtargetTextureに、コマンドバッファによってシーンがコピーされたバッファを適用し、また取り除きたいレイヤーを設定した上でGUI用カメラのレンダリングを行っています。

cullingMaskが、レンダリング対象となるかどうかのマスク情報を設定するレイヤーマスクです。
ここに、今回はMenuUIレイヤーを持つ要素を外してレンダリング_guiCamera.Render())しています。

最後にtargetTexturecullingMaskを元に戻して終わりです。

これで、最終的に得たい、不要なGUI要素が除かれた状態でシーンをキャプチャすることができます。

コード全文

最後に、今回のサンプルのコード全文を掲載しておきます。
なお、撮影したスクショをファイルに保存する処理についてはGithubのプロジェクトを参照してください。
(今回の主題からははずれるので割愛します)

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

public class SelectGUICapture : MonoBehaviour
{
    [SerializeField, Tooltip("GUIをレンダリングしているカメラ")]
    private Camera _guiCamera = null;

    [SerializeField, Tooltip("キャプチャするタイミング")]
    private CameraEvent _cameraEvent = CameraEvent.BeforeImageEffects;

    [SerializeField, Tooltip("合成時に無視されるUIのレイヤー")]
    private LayerMask _captureTargetLayer = -1;

    private Camera _mainCamera = null;
    private RenderTexture _buf = null;
    private CommandBuffer _commandBuffer = null;

    #region ### MonoBehaviour ###
    private void Awake()
    {
        CreateBuffer();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            TakeScreenshot();
        }
    }

    /// <summary>
    /// 動作確認用にGizmoでテクスチャを表示する
    /// </summary>
    private void OnGUI()
    {
        if (_buf == null) return;
        GUI.DrawTexture(new Rect(5f, 5f, Screen.width * 0.5f, Screen.height * 0.5f), _buf);
    }
    #endregion ### MonoBehaviour ###

    /// <summary>
    /// バッファを生成する
    /// </summary>
    private void CreateBuffer()
    {
        _buf = new RenderTexture(Screen.width, Screen.height, 0);

        _commandBuffer = new CommandBuffer();
        _commandBuffer.name = "CaptureScene";
        _commandBuffer.Blit(BuiltinRenderTextureType.CurrentActive, _buf);
    }

    /// <summary>
    /// スクリーンショットを撮影する
    /// </summary>
    public void TakeScreenshot()
    {
        AddCommandBuffer();

        StartCoroutine(WaitCapture());
    }

    /// <summary>
    /// コマンドバッファの処理を待つ
    /// </summary>
    private IEnumerator WaitCapture()
    {
        yield return new WaitForEndOfFrame();

        BlendGUI();

        RemoveCommandBuffer();
    }

    /// <summary>
    /// メインカメラにコマンドバッファを追加する
    /// </summary>
    private void AddCommandBuffer()
    {
        if (_mainCamera == null)
        {
            _mainCamera = Camera.main;
        }

        _mainCamera.AddCommandBuffer(_cameraEvent, _commandBuffer);
    }

    /// <summary>
    /// メインカメラからコマンドバッファを削除する
    /// </summary>
    private void RemoveCommandBuffer()
    {
        if (_mainCamera == null)
        {
            return;
        }

        _mainCamera.RemoveCommandBuffer(_cameraEvent, _commandBuffer);
    }

    /// <summary>
    /// GUI要素をブレンドする
    /// </summary>
    private void BlendGUI()
    {
        _guiCamera.targetTexture = _buf;

        int tmp = _guiCamera.cullingMask;
        _guiCamera.cullingMask = _captureTargetLayer;

        _guiCamera.Render();

        _guiCamera.cullingMask = tmp;

        _guiCamera.targetTexture = null;
    }
} 

揚力を計算して滑空する

概要

今モックで作成しているVRコンテンツに、滑空の要素を入れたかったので揚力の計算について調べてみたのでそのメモです。

ちなみに適当に飛行機っぽい形状を作って適用した動画はこんな感じです↓

また、実装にあたって参考にさせてもらったのは以下の記事です。

64章:二次元翼の揚力と抗力

www.cfijapan.com

blog.goo.ne.jp

blog.goo.ne.jp

揚力の計算

揚力(Lift)は以下の数式から求めることができるようです。


L = C_L \frac{ρ}{2} q^2 A

D = C_D \frac{ρ}{2} q^2 A

ここで、 C_Lは無次元の係数で揚力係数、 C_Dは無次元の係数で抗力係数といいます。
また、数式の記号の意味は以下となります。

 ρは空気密度、 qは流速、 Aは翼の面積です。

それぞれの単位は以下のようになります。

 
- ρ ... kg/m^3  \\
- q ... m/s \\
- A ... m^2

流体の密度 ρですが、場所によって異なるようで、海面高度の大気中は大体[tex: 1.2250kg/m3]となるようです。
また、揚力係数、抗力係数は実験的に求められているようで、こちらの記事から画像を引用させていただくと以下のようなグラフになるようです。

力のかかる方向

揚力、抗力については上で書いた通りです。
この力のかかる方向を図示すると以下のようになります。

f:id:edo_m18:20190113152919j:plain

 \thetaが進行方向と流体(空気)との成す角度です。
そして Lが揚力(Lift)を、 Dが抗力(Drag)を、 mgが重力方向を表しています。

この図を見てもらうと分かりますが、揚力は進行方向に対して垂直、抗力は進行方向と平行(ただし逆向き)となります。

揚力のかかる方向を求める

色々な記事を見ても、揚力のかかる方向が垂直であることは示されているものの、「じゃあ実際プログラムするときに垂直方向ってどっちさ?」となり、色々考えた結果、以下のようにして求めるようにしたところ、それっぽく動いているのでこれを採用しています。

実際は流体力学などから、流体、そして渦の生成、気圧など様々な条件から方向が定まり、実際に揚力のかかる方向を計算するのだと思いますが、今回はあくまで「それっぽく」動くことが目的だったので単純に垂直な方向を求めています。

考え方としては以下のような感じです。

  1. 進行方向との垂直方向、つまり外積の向く向きを採用する
  2. しかし「飛行機が逆さま」になっていることも考慮すると、もうひとつの軸の取り方で結果が変わってしまう
  3. 平行に近い角度で進行している場合は、翼の「右」方向と進行方向との外積が「上」として都合が良さそう(進行方向との外積なので必ず進行方向に対して垂直となる)
  4. 反対の考慮は?
  5. 翼の「上」ベクトル(leftWing.transform.up)と進行方向との外積方向をまず求める
  6. 通常飛行(翼の前ベクトルと平行的な方向)の場合においては、翼の右ベクトル(leftWing.transform.rightと(5)のベクトルは概ね同じ方向を向く
  7. 翼に対して後ろ方向に進行している場合は右ベクトルとは反対方向を向くようになる
  8. (5)のベクトルと右ベクトルとの「内積」を計算し、マイナスとなる場合は逆と判断
  9. -leftWing.transform.rightを計算のベクトルに用いる

というようなフローで解決するようにしてみました。

実際に、翼の上ベクトルと進行方向との外積で求めたベクトルの動きを動画にしてみると以下のような感じになります。


進行方向の判別(揚力の適用向き用)

黄色のバーが進行方向を表し、紫色のバーが外積によって求めたベクトルです。
動画を見てもらうと分かりますが、進行方向が翼に対して前方に向いている場合は、翼の右ベクトルとの角度は鋭角になっており、進行方向が逆転した場合に鈍角になることが分かります。

このことから、翼の右ベクトルと求めたベクトルとの内積を取り、結果がマイナス(=鈍角)なら進行方向が想定と逆、とみなすことができるわけです。

コードにすると以下のような感じです。

Vector3 dir = v.normalized;

// 進行方向に対しての「右」を識別する
Vector3 lcheckDir = Vector3.Cross(_leftWing.up, dir);

float lcheckd = Vector3.Dot(_leftWing.right, lcheckDir);

Vector3 lright = (lcheckd < 0) ? -_leftWing.right : _leftWing.right;

// ldirが揚力の働く方向(Lift Direction)
Vector3 ldir = Vector3.Cross(dir, lright);

コード全文

そんなに長くないのでコード全文を載せておこうと思います。

ちなみに「揚力係数」と「抗力係数」についてはアニメーションカーブを用いて、概ね以下のような感じで設定してそこから値を取得するようにしています。

揚力係数のアニメーションカーブ(グラフ)

f:id:edo_m18:20190113172042p:plain

抗力係数のアニメーションカーブ(グラフ)

f:id:edo_m18:20190113172125p:plain

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

public class LiftTest : MonoBehaviour
{
    [SerializeField]
    private Transform _body;
    
    [SerializeField]
    private Transform _leftWing;

    [SerializeField]
    private Transform _rightWing;

    [SerializeField]
    private AnimationCurve _liftCoeff;

    [SerializeField]
    private AnimationCurve _dragCoeff;

    [SerializeField]
    private ParticleSystem _lparticle;

    [SerializeField]
    private ParticleSystem _rparticle;

    [SerializeField]
    private float _pitchSpeed = 0.5f;

    [SerializeField]
    private float _rollSpeed = 0.5f;

    [SerializeField]
    private float _acc = 1000f;

    [SerializeField]
    private float _rho = 1.225f;

    [SerializeField]
    private float _area = 12f;

    [SerializeField]
    private float _initVelocity = 30f;

    [SerializeField]
    private bool _useLift = true;

    private Rigidbody _rigid;

    private void Start()
    {
        _rigid = GetComponent();
        _rigid.velocity = transform.forward * _initVelocity;

        _lparticle.Stop();
        _rparticle.Stop();
    }

    private void Update()
    {
        Control();
    }

    private void FixedUpdate()
    {
        if (_useLift)
        {
            CalcLift();
        }
    }

    private void Control()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _lparticle.Play();
            _rparticle.Play();
        }

        if (Input.GetKeyUp(KeyCode.Space))
        {
            _lparticle.Stop();
            _rparticle.Stop();
        }

        if (Input.GetKey(KeyCode.Space))
        {
            _rigid.AddForce(_body.forward * _acc);
        }

        if (Input.GetKey(KeyCode.DownArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.right), -_pitchSpeed);
        }

        if (Input.GetKey(KeyCode.UpArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.right), _pitchSpeed);
        }

        if (Input.GetKey(KeyCode.LeftArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.forward), _rollSpeed);
        }

        if (Input.GetKey(KeyCode.RightArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.forward), -_rollSpeed);
        }
    }

    private void CalcLift()
    {
        Vector3 lpos = _leftWing.position;
        Vector3 rpos = _rightWing.position;

        Vector3 lup = _leftWing.up;
        Vector3 rup = _rightWing.up;

        Vector3 v = _rigid.velocity;

        float m = v.magnitude;
        float velocitySqr = m * m;

        Vector3 dir = v.normalized;

        // 揚力、抵抗ともに使う係数の計算(ρ/2 * q^2 * A)
        // ρ ... 密度
        // q ... 速度
        // A ... 面積
        float k = _rho / 2f * _area * velocitySqr;

        Debug.DrawLine(_body.position, _body.position + dir, Color.black);

        float ldot = Vector3.Dot(lup, dir);
        float lrad = Mathf.Acos(ldot);

        float rdot = Vector3.Dot(rup, dir);
        float rrad = Mathf.Acos(rdot);

        float langle = (lrad * Mathf.Rad2Deg) - 90f;
        float rangle = (rrad * Mathf.Rad2Deg) - 90f;

        float lcl = _liftCoeff.Evaluate(langle);
        float rcl = _liftCoeff.Evaluate(rangle);

        // 単位: N = kg・m/s^2
        float ll = lcl * k;
        float rl = rcl * k;

        // 進行方向に対しての「右」を識別する
        Vector3 lcheckDir = Vector3.Cross(_leftWing.up, dir);
        Vector3 rcheckDir = Vector3.Cross(_rightWing.up, dir);

        float lcheckd = Vector3.Dot(_leftWing.right, lcheckDir);
        float rcheckd = Vector3.Dot(_rightWing.right, rcheckDir);

        Vector3 lright = (lcheckd < 0) ? -_leftWing.right : _leftWing.right;
        Vector3 rright = (rcheckd < 0) ? -_rightWing.right : _rightWing.right;

        Vector3 ldir = Vector3.Cross(dir, lright);
        Vector3 rdir = Vector3.Cross(dir, rright);

        Vector3 lv = ldir * ll;
        Vector3 rv = rdir * rl;

        Debug.DrawLine(_leftWing.position, _leftWing.position + lv, Color.cyan);
        Debug.DrawLine(_rightWing.position, _rightWing.position + rv, Color.cyan);

        float lcd = _dragCoeff.Evaluate(langle);
        float rcd = _dragCoeff.Evaluate(rangle);

        float ldrag = lcd * k;
        float rdrag = rcd * k;

        Vector3 drag = -dir * (ldrag + rdrag);

        Debug.DrawLine(_body.position, _body.position + drag);

        Vector3 force = (lv + rv + drag) * Time.deltaTime;;

        _rigid.AddForce(force);
    }
}

UnityのCamera#ScreenToWorldPointを自前で計算してみる

概要

Screen Spaceの座標をシーンのワールド座標に変換して、その位置になにかする、というのはよくある処理だと思います。
(例えば画面をタップしたらその先にレイを飛ばしてなにかする、とか)

そのあたりは当然、Unityは準備してくれているのだけど、中でなにをしているか知らずに使うのは(毎度のことながら)気持ち悪いので色々やってみたメモです。

Camera#ScreenToWorldPointで簡単に変換

まず、Unityの機能を使う場合であればCamera#ScreenToWorldPointを利用することで簡単に座標を求めることができます。

こんな感じ↓

Camera cam = Camera.main;

Vector2 mousePos = new Vector2();
mousePos.x = Input.mousePosition.x;
mousePos.y = Input.mousePosition.y;

Vector3 point = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, cam.nearClipPlane));

こうすると、Z値にnearClipPlaneを渡しているので、つまりはクリックした位置の表示されるぎりぎりのところの座標を得ることができます。

実際に実行するとこんな感じで、クリックした位置+カメラのnearClipPlane位置にSphereが生成されているのが分かるかと思います↓
f:id:edo_m18:20190106110308g:plain

今回はこれと同じ値を自前で算出するのを目的としています。

座標変換の過程を知る

さて、今回の話は主に座標変換の話となります。3D空間に配置されたオブジェクトを、いくつもの座標変換行列によって変換し、最終的にスクリーン座標系に移動させるのが一連の座標変換です。まずはこれを理解しないことには始まりません。

どういう座標変換が必要かは以下のようになります。

  1. モデル座標変換
  2. ビュー座標変換
  3. プロジェクション座標変換
  4. 正規化デバイス系座標変換
  5. スクリーン座標変換

ひとつのオブジェクトを表示するために、実に5回もの座標変換を行っているわけなんですね。そして大半の変換には「行列」を用います。

座標変換のための行列の掛け算

座標変換には行列を使うと書きました。各座標変換にはそれぞれ行列があり、それをベクトルに掛け算していくことで指定した座標へ変換していくことになります。

具体的には以下のような感じです。

 
\vec{V_s} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p
  •  \vec{V_s} ... スクリーン座標での位置(ベクトル)
  •  \vec{v} ... ローカルの位置ベクトル
  •  M ... モデル座標変換行列
  •  V ... ビュー座標変換行列
  •  P ... プロジェクション座標変換行列
  •  V_p ... ビューポート座標変換行列

正規化デバイス座標系については、プロジェクション座標変換後(同次座標系)のベクトルのw要素で除算することで得られる変換のため、行列は存在しません。

こうしてはるばる変換の旅をしたローカルの位置ベクトルが最終的に画面の特定の位置に表示される、というわけです。

座標変換を「さかのぼる」には逆行列を使う

そして座標変換されたベクトルに対して、逆順にそれぞれの座標変換で用いた行列の「逆行列」を掛けることで変換をもとに戻すことができます。

行列の使い方、座標変換の細かい挙動などについてはマルペケさんの以下の記事がとても参考になります。特に「③ 検証3:あるモデルの世界へ連れ込む」の節が座標変換について詳しく書かれています。

その60 変換行列A×BとB×Aの違いを知ろう

上で説明したスクリーン座標まで旅をしたベクトル\(\vec{v}\)を、再びワールド空間に戻すには以下のようにします。

 
\vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1}

※ ちなみにUnityの行列では「列オーダー」のメモリレイアウトを採用しているため、行列の掛ける順番が左右反転することに注意してください。

ちなみに行列のオーダーや掛ける順番などについては前回の記事でまとめたのでそちらをご覧ください。

edom18.hateblo.jp

とある行列に、その逆行列を掛けると単位行列となります。つまり、上の計算はそれぞれの逆行列を順番に掛けているのですべてが単位行列\(E\)となり、結果的にもとのベクトルだけが残る、というわけです。

 
\begin{eqnarray}
\vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} &=& \vec{v} \cdot M \cdot V \cdot P \cdot E \cdot P^{-1} \cdot V^{-1}  \\\
&=& \vec{v} \cdot M \cdot V  \cdot E \cdot  V^{-1}  \\\
&=& \vec{v} \cdot M  \cdot E \\\
&=& \vec{v} \cdot M
\end{eqnarray}

最後、 M行列が残っていますが、(今回は)ワールド座標に変換するのが目的なのでワールド変換より前には戻らないためです。( Mを戻してしまうと、該当オブジェクトのローカル空間にまで戻ってしまうためです)

行列自体の話題ではないので、これ以上の細かい話は割愛します。

ビューポート行列はグラフィクスAPIで異なる

上記の V_pはビューポート行列を表しています。そしてこのビューポート行列はグラフィクスAPIによって異なります。 詳細については以下の記事が参考になりました。

blog.natade.net

今回はMacで試していたのでOpenGLでの行列でテストしました。具体的には以下の形の行列です。

 
\begin{vmatrix}
\frac{Screen Width}{2} &0 &0 &0 \\\
0 &\frac{Screen Height}{2} &0 &0 \\\
0 &0 &\frac{MaxZ - MinZ}{2} &0 \\\
Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &\frac{MaxZ + MinZ}{2} &1
\end{vmatrix}

ちなみにDirextXでは以下のようになるようです。

 
\begin{vmatrix}
\frac{Screen Width}{2} &0 &0 &0 \\\
0 &-\frac{Screen Height}{2} &0 &0 \\\
0 &0 &MaxZ - MinZ &0 \\\
Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &MinZ &1
\end{vmatrix}

このあたりは、正規化デバイス座標系でのZ値の取る値が違う点によるものだと思います。

C#での実装

さて、OpenGL版のものをC#で表すと以下のようになります。

Camera cam = Camera.main;

Matrix4x4 viewportInv = Matrix4x4.identity;
viewportInv.m00 = Screen.width / 2f;
viewportInv.m03 = Screen.width / 2f;
viewportInv.m11 = Screen.height / 2f;
viewportInv.m13 = Screen.height / 2f;
viewportInv.m22 = (cam.farClipPlane - cam.nearClipPlane) / 2f;
viewportInv.m23 = (cam.farClipPlane + cam.nearClipPlane) / 2f;

そして、生成した行列の逆行列を求めて最終的な結果を得ます。

実際に同じ値を算出したコードは以下のようになります。

// pointはスクリーンの位置
private Vector3 ApplyProjectionMatrix(Vector2 point)
{
    if (_cam == null)
    {
        _cam = Camera.main;
    }

    Matrix4x4 viewportInv = Matrix4x4.identity;
    viewportInv.m00 = viewportInv.m03 = Screen.width / 2f;
    viewportInv.m11 = Screen.height / 2f;
    viewportInv.m13 = Screen.height / 2f;
    viewportInv.m22 = (_cam.farClipPlane - _cam.nearClipPlane) / 2f;
    viewportInv.m23 = (_cam.farClipPlane + _cam.nearClipPlane) / 2f;
    viewportInv = viewportInv.inverse;

    Matrix4x4 viewMatInv = _cam.worldToCameraMatrix.inverse;
    Matrix4x4 projMatInv = _cam.projectionMatrix.inverse;
    Matrix4x4 matrix = viewMatInv * projMatInv * viewportInv;

    Vector3 pos = new Vector3(point.x, point.y, _cam.nearClipPlane);

    float x = pos.x * matrix.m00 + pos.y * matrix.m01 + pos.z * matrix.m02 + matrix.m03;
    float y = pos.x * matrix.m10 + pos.y * matrix.m11 + pos.z * matrix.m12 + matrix.m13;
    float z = pos.x * matrix.m20 + pos.y * matrix.m21 + pos.z * matrix.m22 + matrix.m23;
    float w = pos.x * matrix.m30 + pos.y * matrix.m31 + pos.z * matrix.m32 + matrix.m33;

    x /= w;
    y /= w;
    z /= w;

    return new Vector3(x, y, z);
}

ここで行っている計算は、ビューポート行列を生成したあと、ビューポート行列、ビュー行列、プロジェクション行列の逆行列を求め、それを合算し、最後にスクリーン座標位置のベクトルにその行列を適用しているところです。そして後半のx, y, z, wは同次座標の計算を行っている部分です。通常のプロジェクション座標変換ではこのwで除算することで遠くものは小さく、近くのものは大きく、というパースが効いた自然な形に変換するための処理です。

そして今回は逆行列を用いているため、その逆変換、つまり「小さいものも大きいものも通常のサイズに直す」という処理になります。あとは算出された値をオブジェクトの位置ベクトルに設定してやれば、冒頭の動画のように、スクリーンをタップした位置にオブジェクトが移動します。

Unityの行列の扱いとベクトルのオーダー周りについてまとめておく

概要

自分で直に行列変換周りの処理を書くときに、掛ける順番やオーダー周りについていつも混乱するのでまとめておきます。

座標系の向き

これは、行列のオーダーには直接関係はありませんがよく混乱するので書いておきます。
Unityでは「左手系」の座標系を採用しています。

エディタ右上の軸情報を見ると、右が正、上が正、そして奥が正となる軸を取っていることが分かります。

f:id:edo_m18:20190103193700p:plain

ちなみに左手系とは、左手の親指をX軸プラス、人差し指をY軸プラスにしたときに、それぞれの指に直行するように中指を曲げたときに指が向く方向がプラスとなる座標系です。

行列の配列要素の並び(メモリレイアウト)

さて、本題の行列に関して。
なぜ、列オーダー、行オーダーという名称があるのでしょうか。

その答えは行列の表現にあります。

数学的な「行列」はm x n行列となり、プログラムでは2次元配列として表すのが直感的です。
しかし3Dグラフィクスで扱う行列は通常、2次元配列ではなく1次元配列で表現されます。

そのため、行列の各要素をどういう順番で1次元配列として表現するか、が2通りあることが分かると思います。
つまり「列オーダー」と「行オーダー」です。

図にすると以下のような感じです。
行列の各要素が配列の添字としてはいくつなのか、を示しています。

f:id:edo_m18:20190103230507p:plain

実際に計算を行ってみると分かりますが、どちらの計算も必ず以下のように行列要素とベクトル要素が掛けられるようになっています。

// xだけ計算してみる
// 列オーダー版
x' = mat[0] * x + mat[4] * y + mat[8] * z + mat[12] * 1

// 行オーダー版
x' = x * mat[0] + y * mat[4] + z * mat[8] + 1 * mat[12]

基本的には「行列の計算」という数学上のルールに変化があるわけではないので、単純に配列のメモリレイアウトに依存して掛ける方向が変わる、ということですね。

列を主とするか、行を主とするかで添字が異なっているのが分かるかと思います。
これをしっかり把握しておかないと、行列の掛ける順番を間違えて想定していた結果にならない、ということが往々にしてあるわけです。

Unityでの掛ける順番は列オーダー

列オーダー、行オーダーを説明したところで、Unityではどういうふうに計算するのでしょうか。
まずはそれぞれのプラットフォーム(API)での規則を見てみます。

APIごとの規則

ちなみに既存のグラフィクスAPIの規則は以下となります。

API 座標系の向き オーダー
OpenGL 右手系 列オーダー
DirectX 左手系 行オーダー
Unity 左手系 列オーダー

こうして並べてみると、Unityは「OpenGL」でさらに「左手系」の規則を採用、とどのAPIとも違う規則になっているのが分かりますね。

UnityのC#は「列オーダー」。でもシェーダは「行オーダー」

Unityのドキュメントを見てみると以下のように記載があります。

Matrices in unity are column major.

このことから、C#(CPU)の世界では「列オーダー」であることが分かります。

そして通常、行列はシェーダで利用するケースが多いでしょう。
マルチプラットフォームをサポートしているUnityのシェーダは「Cg」をベースとしたシェーダを記述するのが一般的です。
どうやらCgでは「行オーダー」であることが基本のようです。

そのためか、シェーダでは行優先としてメモリレイアウトがされるようです。
以下の記事で言及されていました。

tech.drecom.co.jp

Unityのシェーダーの世界は行優先であることが分かりました。

上記記事では、C#側で行と列それぞれに値を入れてシェーダ側でどう扱われるか、で判断したようです。

ただし、メモリレイアウトは切り替わっても転置されるわけではないのでご注意を。基本的に計算は列ベクトル前提で行います。

でも計算はC#、シェーダどちらも「列オーダー」

ということなので、基本的にはUnityで行列を扱っている以上は「列オーダー」で考えておいて大丈夫なようです。
実際、よく目にする頂点シェーダの記述も以下のようになっていて、列ベクトルを右側に置いて掛けているのが分かりますね。

// 列ベクトルなので「右側」にベクトルが置かれて計算されている
mul(UNITY_MATRIX_MVP, v.vertex); 

行列の掛ける順番の意味

最後に、行列の掛ける順番について。
3Dグラフィクスでは行列が頻繁に使われ、特に、頂点シェーダからフラグメントシェーダに値を渡す際、一般的な合成行列を掛けて渡します。

具体的には以下の行列です。

  1. モデル座標変換行列
  2. ビュー座標変換行列
  3. プロジェクション変換行列

そしてそれぞれの行列をひとつに「合算」させたものをシェーダに送り、それを各頂点に掛け算してフラグメントシェーダステージに渡す、というのが基本的な動作です。
そしてこれらの行列の頭文字を取ってM(odel) x V(iew) x P(rojection)でMVP行列、なんて呼ばれたりします。

しかしこれ、行オーダーで計算を行うAPI規則に基づくものです。(つまりDirectX
Unityでは、上で示したように「列オーダー」となります。

そのため掛ける順番がMVPではなくPVMとなる点に注意が必要です。
コード例で示すと以下のようになります。

Matrix4x4 m = /* モデル座標変換行列生成 */;
Matrix4x4 v = anyCamera.worldToCameraMatrix;
Matrix4x4 p = GL.GetGPUProjectionMatrix(anyCamera.projectionMatrix);

// 掛ける順番が逆
Matrix4x4 mvp = p * v * m;

GL.GetGPUProjectionMatrixで変換を行う

少し余談となりますが、上記例にしれっと出てきたGL.GetGPUProjectionMatrix
これは、プラットフォーム依存となる「正規化デバイス座標系」での、near / far表現を適切に変換するための処理をしてくれるヘルパー関数です。

こちらの処理については以下の記事がとても詳しく検証、解説してくれているのでそちらを参考にするのがいいでしょう。

【Unity】【数学】Unityでのビュー&プロジェクション行列とプラットフォームの関係 – 株式会社ロジカルビート

また、数式を用いて解説を行ってくれているこちらの記事も参考に。

tech.drecom.co.jp

ざっくりとだけ解説しておくと、「正規化デバイス座標系」というのは、プロジェクション変換行列を適用し、均一な矩形領域(ビューボリューム)へと変換されたあとの座標系のことです。

そしてこの座標系の取るZの値が、APIによって異なります。
より具体的に言うと0~1となるのか、-1~1となるのか、という違いがあります。

これを適切に設定しないと、シェーダに値を送った際に意図した結果にならくなってしまいます。

なお、これを考慮した、自分で生成した行列をシェーダに送って、標準のMVP行列と同じような動作をさせるためには以下のように計算します。

// シーンビューでも適切に動くように`OnWillRenderObject`を利用して、Cameraの情報をそれぞれ取得
private void OnWillRenderObject()
{
    if (Camera.current == null)
    {
        return;
    }

    Camera cam = Camera.current;

    // スケールの行列
    Matrix4x4 sMat = Matrix4x4.Scale(_scale);
    // 平行移動行列
    Matrix4x4 tMat = Matrix4x4.Translate(_translate);
    // 上記2行列を「合成」し、さらにMVP行列になるように計算
    Matrix4x4 matrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, true) * cam.worldToCameraMatrix * tMat * sMat;

    // シェーダに送る
    _ren.material.SetMatrix("_Matrix", matrix);
}

シェーダで凝ったことをやろうとしたり、CPU側で行列計算しそれをシェーダに送る、みたいな処理が発生した際に混乱しがちなのでまとめてみました。
まとめる際に色々調べて分かったのは「複雑極まりない」ということでしょうかw

どれかひとつのプラットフォーム(API)に絞るだけならこうはならないのかもしれませんが、マルチプラットフォーム対応の弊害でしょう。
このあたりはしっかりと基礎を身につけておかないといつまでも混乱するのでしっかりと身につけておきたいところです。