e.blog

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

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

Unityでガウシアンブラーを実装する

概要

よく使う&表現力の高いぼかし処理。

以前にもぼかしを利用したコンテンツを作成したり、記事を書いたりしていましたがちゃんとぼかしだけにフォーカスしたことはなかったので改めて書きたいと思います。

今回のサンプルを動かしたデモ↓

ちなみに以前、ぼかし関連の内容が含まれていたコンテンツ/記事はこんな感じ↓

qiita.com

qiita.com


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

light11.hatenadiary.com

あとこちらも。

wgld.org

また、今回のサンプルは以下にアップしてあります。

github.com

ガウス関数とは

Wikipediaから引用させてもらうと以下のように説明されています。

ガウス関数ガウスかんすう、英: Gaussian function)は、


a\ exp\biggl(- \frac{(x - b)^2}{2c^2}\biggr)

の形の初等関数である。なお、 2c^2 のかわりに  c^2 とするなど、表し方にはいくつかの変種がある。

ガウシアン関数、あるいは単にガウシアンとも呼ばれる。

また、特徴として以下のようにも説明されています。

特徴

正規分布関数(正規分布確率密度関数)として知られる

 \frac{1}{\sqrt{2 \pi  σ}} exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)

は、ガウス関数の1種である。

ということで、今回話題にするのはこちらの関数です。


y = \frac{1}{\sqrt{2 \pi  σ}} exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)

ここでの大事な点としては「正規分布関数」ということでしょう。

これもWikipediaから引用させてもらうと、

平均値の付近に集積するようなデータの分布を表した連続的な変数に関する確率分布

と書かれています。
ガウス関数では上記関数の μの値を0にすると、 x = 0を中心とした釣り鐘型のグラフを描きます。
これが、ぼかしを掛ける重み付けとして重宝する点です。
そしてさらに、 σの値を調整することでグラフの形を調整することができるため、ぼかし具合もパラメータのみで設定できるのが利用される理由でしょう。

ちなみにこの関数をdesmosでグラフ化すると以下のようになります。

www.desmos.com

上記グラフは σの値を変化させた結果です。
尖っていたり、平になっていたり、と形が大きく変化しているのが分かるかと思います。

そして今回利用する関数は、定数部分を1にし、中央がx = 0となるよう、 μの値を0とした以下の関数を用います。


y = exp\biggl(- \frac{(x)^2}{2 σ^2}\biggr)

これをグラフにすると以下の形になります。

www.desmos.com

すべてのグラフが1を最大値として色々な形に変化しているのが分かるかと思います。
このグラフの yの値を重みとし、さらに xの値をサンプリング点からの距離として用いることでブラーを実現します。

ブラーの仕組み

さて、ガウス関数を使ってブラーさせることが分かりましたが、これをどう利用するのか。
まずは以下の画像を見てください。

重みの計算

上がガウス関数のグラフの様子、下がテクスチャからテクセルをサンプリングする様子です。
(1)の部分が普通にフェッチするテクスチャの位置を表しています。

そこから(2), (3), (4)が追加でサンプリングするオフセット位置です。
そしてその数字とグラフ上に書かれた数字がそれぞれ、重みとして紐付いた状態を表しています。

つまり、(1)の場合は重み1、(2)の場合はおよそ0.58、という具合です。
中心から離れるにつれて徐々に重みが減っていっているのが分かるかと思います。

ガウス関数を思い出してみると exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)となっているので、大まかにはexpの値に近いカーブを描くことなるわけです。
(そして引数が x^2を用いているためマイナスが取れて左右対称になっている、というわけですね)

ただし、重みは通常「合計して1になる」必要があるので、最終的な重みは全重みの合計でそれぞれの値を割った値として正規化して利用します。

上下左右は別々に計算できる

概ねブラーの処理についてはイメージできたと思います。
ガウシアンブラーを利用するもうひとつの利点として以下のような性質があります。

wgld.orgの記事から引用させていただくと以下のように記載されています。

さらに、ガウス関数を三次元で用いる場合には x 方向と y 方向を切り離して処理することができます。実はこれが非常に重要です。

どういうことかと言うと、仮に上記のように「切り離して処理することができない」とすると、1テクセルの計算に対しては上の図で言うと、数字の書かれていないテクセルに関してもフェッチして処理しないとなりません。
つまり7 x 7 = 49回のフェッチが必要となります。

しかしこれを分離して考えることができる、という性質から「横方向に7回」、「縦方向に7回」というふうに分けて処理することができることを意味します。
結果、7 x 7 = 49だったフェッチ回数が7 + 7 = 14という回数に劇的に少なくなるわけです。
(そしてこれは、対象とするテクセル数が増えれば増えるほど顕著に差が出てきます)

実際にブラーがかかっていく様子を、Frame Debuggerで出力された画像を見てみると以下のように3回のパスを経て生成されています。

まず、テクスチャをコピーします。(またブラーの性質上、対象画像が縮小されてもあまり問題ないため半分のスケールにしてからコピーしています)

そしてまず、横方向にブラーをかけます↓

さらに横方向ブラーがかかった画像に対して縦にブラーをかけます↓

最終的にしっかりとブラーがかかっているのが分かるかと思います。

シェーダを実装する

仕組みが分かったところでシェーダの実装です。
あまり長いコードではないので全文載せます。

Shader "Custom/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;

            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)) * _Weights[j];
                }

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

                return col;
            }
            ENDCG
        }
    }
}

一番重要なのはフラグメントシェーダでしょう。
samplingCount分だけfor文でループしているのが分かるかと思います。
ふたつループがあるのは、中心から左右(あるいは上下)にテクセルをフェッチするためそれぞれ2回に分けて書いているだけです。

必要となるオフセット位置についてはC#側から設定して計算しています。
また同様に、重みに関してもC#側から渡しています。

理由としては、パラメータが変化しなければ重みに変化がないためアップデートが必要なときだけ計算を行っているためです。

次に、C#の実装を見てみます。

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

namespace Sample
{
    public class GaussianBlur : MonoBehaviour
    {
        [SerializeField]
        private Texture _texture;

        [SerializeField]
        private Shader _shader;

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

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

        private Material _material;

        private Renderer _renderer;

        // Apply sevral blur effect so use as double buffers.
        private RenderTexture _rt1;
        private RenderTexture _rt2;

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

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

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

            UpdateWeights();

            Blur();
        }
        #endregion ### MonoBehaviour ###

        /// <summary>
        /// Initialize (setup)
        /// </summary>
        private void Initialize()
        {
            if (_isInitialized)
            {
                return;
            }

            _material = new Material(_shader);
            _material.hideFlags = HideFlags.HideAndDontSave;

            // Down scale.
            _rt1 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);
            _rt2 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);

            _renderer = GetComponent<Renderer>();

            UpdateWeights();

            _isInitialized = true;
        }

        /// <summary>
        /// Do blur to the texture.
        /// </summary>
        public void Blur()
        {
            if (!_isInitialized)
            {
                Initialize();
            }

            Graphics.Blit(_texture, _rt1);

            _material.SetFloatArray("_Weights", _weights);

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

            // for horizontal blur.
            _material.SetVector("_Offsets", new Vector4(x, 0, 0, 0));

            Graphics.Blit(_rt1, _rt2, _material);

            // for vertical blur.
            _material.SetVector("_Offsets", new Vector4(0, y, 0, 0));

            Graphics.Blit(_rt2, _rt1, _material);

            _renderer.material.mainTexture = _rt1;
        }

        /// <summary>
        /// Update waiths by gaussian function.
        /// </summary>
        private void UpdateWeights()
        {
            float total = 0;
            float d = _blur * _blur * 0.001f;

            for (int i = 0; i < _weights.Length; i++)
            {
                // Offset position per x.
                float x = i * 2f;
                float w = Mathf.Exp(-0.5f * (x * x) / d);
                _weights[i] = w;

                if (i > 0)
                {
                    w *= 2.0f;
                }

                total += w;
            }

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

C#のコードのほうがやや長いですね。
ただ、フィールドの定義以外はそこまで処理は多くありません。

まず、今回の主題である「ガウス関数」での重み付けを更新しているのがUpdateWeightsメソッドです。

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

    for (int i = 0; i < _weights.Length; i++)
    {
        // Offset position per x.
        float x = i * 2f;
        float w = Mathf.Exp(-0.5f * (x * x) / d);
        _weights[i] = w;

        if (i > 0)
        {
            w *= 2.0f;
        }

        total += w;
    }

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

_blurはブラーの強さの係数です。
続くループ処理では各テクセルの重みを計算しています。

xは中心からどれくらい離れているか、を示す値です。まさに関数のxと同義ですね。
ただ、2倍にしているのは入力であるxの値を若干オフセットさせています。(オフセットは値が大きめにばらけるようにしているだけなので、なくても大丈夫です)

そして最後に、求めたそれぞれの重みを、重み全体の合計で割ることで正規化しています。

ブラーのためのダブルバッファ

次に、バッファの準備です。
上で書いたように、縦横それぞれのブラーを適用するためふたつのバッファを用意して処理を行います。
そのため、以下のようにふたつのRenderTexutreを用意します。(と同時に、対象となるテクスチャの半分のサイズにしてダウンスケールしています)

また、ブラーに利用するマテリアルを生成しておきます。

_material = new Material(_shader);
_material.hideFlags = HideFlags.HideAndDontSave;

// Down scale.
_rt1 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);
_rt2 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);

そして実際にブラー処理を行っているのが以下の箇所です。

public void Blur()
{
    Graphics.Blit(_texture, _rt1);

    _material.SetFloatArray("_Weights", _weights);

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

    // for horizontal blur.
    _material.SetVector("_Offsets", new Vector4(x, 0, 0, 0));

    Graphics.Blit(_rt1, _rt2, _material);

    // for vertical blur.
    _material.SetVector("_Offsets", new Vector4(0, y, 0, 0));

    Graphics.Blit(_rt2, _rt1, _material);

    _renderer.material.mainTexture = _rt1;
}

冒頭でテクスチャのコピーを行い、必要な重みを設定しています。
その後、必要なテクセルフェッチ位置のオフセットを計算し設定しています。

ちなみにここでのオフセットは、シェーダを見てもらうと分かりますが、フェッチするテクセルごとのオフセットです。
つまり、仮にここで2(相当。実際はUV値なので少数になる)を渡したとすると、通常のフェッチ位置を0として、2, 4, 6, 8...と、2テクセル隣のテクセルを、サンプリング回数分飛び飛びにフェッチしていくことになるわけです。

なのでオフセットの値を大きくするとボケをより大きくすることができます。
(ただし、飛び飛びでの処理になるので大きすぎるとブラーというより「ズレ」のような効果になります)

今回実装したブラー処理は以上です。

なお、この処理をCommand Bufferなどを用いて適切なタイミングでキャプチャした映像を用いると以下のような、擦りガラス的な表現を行うこともできます。

これは公式のサンプルで掲載されているものですが、こちらのサンプルではもう少しシンプルなブラー処理になっています。

あるいは場面転換とかで全体にブラーを適用してもいいかもしれませんね。