e.blog

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

UnityでWebSocketを使ってブラウザと通信する

概要

WebブラウザからUnityへ(そしてビルドしたアプリにも)データを送信したいことがあったのでWebSocketをUnityで利用する方法およびWebブラウザからデータを送信する方法を試してみたのでそのメモです。

Unity側のWebSocketの利用は以下のライブラリを利用させていただきました。

github.com

ちなみにサーバ側はNode.jsとwsモジュールを利用しています。

今回のサンプルはGitHubにアップしてあるので実際の動作を確認した人はこちらを参照ください。

github.com



ライブラリをビルドする

冒頭のプロジェクトをgit cloneするかzipファイルをダウンロードしプロジェクトをビルドします。
手順は以下の通りです。

プロジェクト内にあるwebsocket-sharp.slnVisual Studioで開きます。

不要なプロジェクトの削除

最初に含まれているいくつかのサンプルがあるとビルドに失敗するようなので該当のプロジェクトを削除します。
具体的にはExampleとついているプロジェクトすべてです。

フォルダごと削除するか、Visual Studioの「ソリューションエクスプローラ」から右クリック→削除を実行することで削除することができます。

ソリューションをビルド

削除後、websocket-sharpソリューションをビルドします。ビルドターゲットはReleaseを選びます。
以下の図のように、ソリューション構成をReleaseに変更します。

f:id:edo_m18:20210124163142p:plain

その後、ソリューションエクスプローラからwebsocket-sharpを選択、右クリックからビルドを選択することでビルドできます。(もしうまく行かない場合はリビルドを選ぶとうまく行くかもしれません)

f:id:edo_m18:20210124162350p:plain

不要プロジェクトの削除などについては以下の記事を参考にさせていただきました。

qiita.com

DLLをプロジェクトに配置

ビルドが正常にできたら以下のパスにDLLファイルが生成されます。それをUnityプロジェクトのPluginsフォルダにコピーします。

DLLの場所

/path/to/project/websocket-sharp/bin/Release/websocket-sharp.dll

C#でWebSocketを扱う

これで無事、C#でWebSocketを扱う準備が整ったのでC#のコードを書いていきます。

WebSocketサーバに接続する

まずはC#側のコードから見ていきましょう。
今回は冒頭で紹介したライブラリを使って実装しています。

まずは簡単に、WebSocketサーバに接続するコードです。

using System.Threading;
using UnityEngine;
using UnityEngine.UI;
using WebSocketSharp;

public class MessageTest : MonoBehaviour
{
    [SerializeField] private string _serverAddress = "localhost";
    [SerializeField] private int _serverPort = 3000;

    private WebSocket _webSocket = null;

    private void Start()
    {
        _webSocket = new WebSocket($"ws://{_serverAddress}:{_serverPort}/");

        // Event handling.
        _webSocket.OnOpen += (sender, args) => { Debug.Log("WebSocket opened."); };
        _webSocket.OnMessage += (sender, args) => { Debug.Log("OnMessage"); };
        _webSocket.OnError += (sender, args) => { Debug.Log($"WebScoket Error Message: {args.Message}"); };
        _webSocket.OnClose += (sender, args) => { Debug.Log("WebScoket closed"); };

        _webSocket.Connect();
    }
}

WebSocketクラスを、URLを引数にインスタンス化してConnectメソッドを呼ぶだけです。シンプルですね。
いくつかコールバックがあるので必要に応じて設定します。

特にOnMessageイベントは必須になるでしょう。引数に渡ってくるMessageEventArgsに送信されてきたデータへの参照があるので適宜これを利用します。

サーバを立てる

今回はNode.jsを利用してWebSocketサーバを立ててテストしました。
サーバ側のコードは以下の通りです。今回はテストなので受信したメッセージを接続しているクライアントにそのまま流しているだけです。

WebSocketのサーバにはwsモジュールを利用しました。

var WebSocket = require('ws');
var ws = WebSocket.Server;
var wss = new ws({port: 3000});

console.log(wss.clients);

wss.brodcast = function (data) {
    this.clients.forEach(function each(client) {
        if (client.readyState === WebSocket.OPEN) {
            client.send(data);
        }
    });
};

wss.on('connection', function (ws) {
    ws.on('message', function (message) {
        var now = new Date();
        console.log(now.toLocaleString() + ' Received.');
        wss.brodcast(message);
    });
});

wsモジュールを読み込んでポート3000で待ち受けているだけですね。
そして来たメッセージをそのままブロードキャストしているだけです。

Webブラウザからデータを送信する

次に、今回の主目的であるWebブラウザからUnityに向かってデータを送信する部分を見てみます。

まずは簡単にHTMLを組みます。(あくまでデータ送信のためのUIを作るため)

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
    <h1>WebSocket x Unityサンプル(Message Sample)</h1>

    <p>
        <input id="serverAddress" value="localhost" />
        <input id="serverPort" value="3000" />
        <input type="button" id="Connect" value="Connect" />
        <input type="button" id="Disconnect" value="Disconnect" />
    </p>

    <p>
        <span id="eventType"></span>
        <span id="dispMsg"></span>
    </p>

    <p>
        <input id="inputField" />
        <input type="button" id="Send" value="Send Message" />
    </p>
</body>
<script type="text/javascript" src="main.js"></script>
</html>

上記サンプルではテキストメッセージを送るだけのシンプルなものです。
実際にデータの送信を行っているWebブラウザ側で実行されているJSは以下です。

class WebSocketController {
    connection = null;
    eventTypeField = null;
    messageDisplayField = null;

    constructor(eventTypeField, messageDisplayField) {
        this.eventTypeField = eventTypeField;
        this.messageDisplayField = messageDisplayField;
    }

    connect(url, port) {
        const URL = "ws://" + url + ":" + port + "/";
        console.log("Will connect to " + URL);

        // Connect to the server.
        this.connection = new WebSocket(URL);

        // 接続通知
        this.connection.onopen = (event) => {
            this.eventTypeField.innerHTML = "通信接続イベント受信";
            this.messageDisplayField.innerHTML = event.data === undefined ? "--" : event.data;
        };

        //エラー発生
        this.connection.onerror = (error) => {
            this.eventTypeField.innerHTML = "エラー発生イベント受信";
            this.messageDisplayField.innerHTML = error.data;
        };

        //メッセージ受信
        this.connection.onmessage = (event) => {
            console.log("Received a message [" + event.data + "]");

            this.eventTypeField.innerHTML = "メッセージ受信";
            this.messageDisplayField.innerHTML = event.data;
        };

        //切断
        this.connection.onclose = () => {
            this.eventTypeField.innerHTML = "通信切断イベント受信";
            this.messageDisplayField.innerHTML = "";
        };
    }

    disconnect() {
        console.log("Will disconnect from " + URL);
        this.connection.close();
    }

    sendMessage(message) {
        console.log("Will send message.");
        this.connection.send(message);
    }
}

let eventTypeField = document.getElementById("eventType");
let messageDisplayField = document.getElementById("dispMsg");

const ws = new WebSocketController(eventTypeField, messageDisplayField);

document.getElementById("Connect").addEventListener('click', () => {
    let url = document.getElementById("serverAddress").value;
    let port = document.getElementById("serverPort").value;
    ws.connect(url, port);
});

document.getElementById("Disconnect").addEventListener('click', ws.disconnect);

document.getElementById("Send").addEventListener('click', () => {
    var message = document.getElementById("inputField").value;
    ws.sendMessage(message);
});

JS書くの久しぶりなので全然モダンな感じじゃないですが目をつぶってくださいw

やっていることはWebSocketサーバに接続して、Inputに入力されているテキストを送るだけのシンプルなものです。

テキストの送信は非常にシンプルに、WebSocketクラスのsendMessageメソッドに文字列を渡すだけです。
これでUnity側にテキストがしっかりと送信されます。

受信したテキストをログ出力するには以下のようにします。

_webSocket.OnMessage += (sender, args) => { Debug.Log(args.Data); };

受信した文字列はMessageEventArgsクラスのDataプロパティに格納されているので、それを参照することで文字列を簡単に取り出すことができます。

WebSocketを利用してバイナリデータを送る

ここからは応用編です。文字列は非常に簡単に送ることができましたが、バイナリデータ、例えば画像などを送る場合は少し工夫をしないとなりません。

特に今回は、様々なデータを送って処理できるようにするために、バイナリデータのフォーマット決めて色々なデータを送れるようにすることを意識して作ったものをメモとして残しておきます。

まずはJS側から(つまり送信側から)のコードを見てみます。
WebSocketControllerクラスに関しては上のコードと同様なので割愛します。sendMessageはbyte配列をそのまま受け付けるので変更せずに利用することができます)

まずは簡単に仕様から。

データ・フォーマットとしては以下を想定しています。
(カッコ内の数字はバイト数です)

+------------------+-----------------
| データタイプ(1) |  データ本体
+------------------+-------------

byte配列の先頭に、データ・タイプを示す1byteのデータを入れます。
0ならカラー情報(文字列)、1なら画像ファイルという具合です。

WebSocketControllerクラスにデータ・タイプを示す値を追加しています。(ここだけ追加された部分です)

class WebSocketController {
    static MessageType = {
        Color: 0,
        Image: 1,
    };
    // 後略
}

実際のデータの組み立てと送信部分は以下のようになります。

カラー情報の送信

最初はカラー情報の送信です。ブラウザのColor Pickerから色を選択したらそれを文字列としてUnity側に送るものです。

function changedColor(evt) {
    var colorData = getColorData(evt.target.value);
    var array = new Uint8Array(colorData);
    var buffer = new ArrayBuffer(array.byteLength + 1);
    var data = new Uint8Array(buffer);
    data.set([WebSocketController.MessageType.Color], 0);
    data.set(array, 1);
    ws.sendMessage(data.buffer);
}

function getColorData(colorStr) {
    return new TextEncoder().encode(colorStr).buffer;
}

changedColor関数はPickerのchangeイベントのコールバックです。
文字列のbyte配列データはTextEncoderクラスを使って行いました。

そして取得したバイト列をUint8Array型配列として参照するようにしつつ、データ・タイプ用に1byteプラスしたArrayBufferインスタンスを生成します。

生成したArrayBufferも同様にUint8Array型配列として参照できるようにしておきます。
この時点で生成されたbuffer変数はまだ空の状態なので、1バイト目にデータ・タイプを設定します。

具体的には以下の部分ですね。

data.set([WebSocketController.MessageType.Color], 0);

Uint8Arrayなどの型配列にはsetメソッドがあるのでそれでデータを設定します。
そしてその後ろに続くように文字列をバイト配列化したデータを追加します。

data.set(array, 1);

第二引数に指定しているのは書き込むデータのオフセットです。
すでに1バイトのデータ・タイプが設定されているのでオフセットは1ということですね。

これで無事、データの準備ができたのでWebSocket経由で送ることができます。

画像データの送信

C#側の(つまり受信側の)コードを見る前に画像データの送信部分も見ておきましょう。

function changedFile(evt) {
    var files = evt.target.files;

    if (files.length <= 0) {
        return;
    }

    var file = files.item(0);
    var reader = new FileReader();

    reader.onload = function () {
        var array = new Uint8Array(reader.result);

        var buffer = new ArrayBuffer(array.byteLength + 1);
        var data = new Uint8Array(buffer);
        data.set([WebSocketController.MessageType.Image], 0);
        data.set(array, 1);

        console.log(data.buffer.byteLength);

        ws.sendMessage(data.buffer);
    }

    reader.readAsArrayBuffer(file);
}

changedFileInputのファイルが変更された際に呼び出されるコールバックです。
選択されたファイルをFileReaderを使って読み出し、それをカラー情報のときと同様にデータ・タイプを先頭にセットして送信しています。

バイト配列の取り出し方が少し異なるだけで基本的にはやっていることは同じです。

C#側で受信する

最後に、ブラウザから送信されてきたデータの受信部分を書いて終わりにしたいと思います。

といっても、バイト配列を適宜操作するだけなのでバイトデータの取り扱いに慣れていればそんなにむずかしいことはしていません。

private void Connect()
{
    // 前略

    SynchronizationContext context = SynchronizationContext.Current;

    // 中略

    _webSocket.OnMessage += (sender, args) =>
    {
        Debug.Log("OnMessage");

        context.Post(_ => { HandleMessage(args.RawData); }, null);
    };
    // 後略
}

// ----------------------------

private void HandleMessage(byte[] data)
{
    DataType type = GetDataType(data);

    switch (type)
    {
        case DataType.Color:
            HandleAsColor(data);
            break;

        case DataType.Image:
            HandleAsImage(data);
            break;
    }
}

private void HandleAsColor(byte[] data)
{
    string colorStr = Encoding.UTF8.GetString(data, 1, 7);

    if (ColorUtility.TryParseHtmlString(colorStr, out Color color))
    {
        Debug.Log(color);
        _material.color = color;
    }
}

private void HandleAsImage(byte[] data)
{
    byte[] texData = new byte[data.Length - 1];
    Array.Copy(data, 1, texData, 0, texData.Length);

    Texture2D tex = new Texture2D(1, 1);
    tex.LoadImage(texData);
    tex.Apply();
    
    _material.mainTexture = tex;

    Debug.Log(tex.width);
}

private DataType GetDataType(byte[] data)
{
    if (data == null || data.Length == 0)
    {
        return DataType.None;
    }

    return (DataType)data[0];
}

実際に操作している部分だけを抜き出しました。
まず冒頭の、メッセージ受信時のコールバック登録です。

private void Connect()
{
    // 前略

    SynchronizationContext context = SynchronizationContext.Current;

    // 中略

    _webSocket.OnMessage += (sender, args) =>
    {
        Debug.Log("OnMessage");

        context.Post(_ => { HandleMessage(args.RawData); }, null);
    };
    // 後略
}

WebSocketの受信イベントは非同期で呼び出されるのでメインスレッド以外で実行されます。
そのため、メインスレッドで実行できるように準備しておきます。(SynchronizationContextの部分です)

そして受信した際には、イベント引数のRawDataにバイト配列が設定されているのでそれを利用して処理しています。

処理に関してはバイト配列から必要なバイト数分取り出して処理をしているだけなので特に解説はいらないでしょう。

ひとつだけ補足しておくと、今回のサンプルコードを書いていて知ったのがColorUtility.TryParseHtmlStringです。16進数などで表された色情報をColor構造体に変換してくれるユーティリティです。
ブラウザのColor Pickerはまさに16進数でカラー情報を表現しているので、受け取った文字列をそのままユーティリティに投げて色情報として受け取っているわけです。

あとはこれをマテリアルなどに設定してやればOKというわけですね。

まとめ

WebSocketを使うとシンプルにデータの送受信が行えることが分かりました。
特にモックなどで「外からこういうデータをさっと渡したいな」みたいなことをやる場合には非常に重宝しそうです。

一方で、Photonなどが提供してくれているような仕組みを作ろうとするとがっつりとフレームワークとして開発することになるので、結局Photonとか使ったほうがいいよね、とはなりそうです。

ちなみに余談ですが、Photonには、今回利用したWebSocketのDLLが梱包されているらしく、後追いでPhotonをインポートしたら定義がバッティングしてエラーが出てしまいました。
なのでPhotonを利用している人は特になにもしなくても今回のコードがそのまま動きます。

UnityでWebAssemblyを扱う

概要

今回はUnityと、ビルドしたアプリからWebAssemblyを利用する手順についてまとめたいと思います。

もともと、ビルド後のアプリに対してコードを追加することができないかなと思っていたところ、たるこすさんの以下の記事を見かけたのがきっかけです。

たるこすさんはこれをさらに汎用化し、VRCなどで振る舞いとモデルを組み合わせたものを使えるようにする、というすばらしい思想で個人プロジェクトを作っているようなので興味がある方はご覧になってください。

zenn.dev

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

github.com

WebAssemblyとは

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

WebAssemblyは、ウェブブラウザのクライアントサイドスクリプトとして動作するプログラミング言語(低水準言語)である。wasmとも称されており、ブラウザ上でバイナリフォーマットの形で実行可能であることを特徴とする。2017年現在開発が進められており、最初の目標としてCとC++からのコンパイルをサポートすることを目指している他、Rustがバージョン1.14以降で、Goがバージョン1.11以降で、Kotlin/Nativeがバージョン0.4以降でで対応するなど、他のプログラミング言語のサポートも進められている。

ブラウザ上でバイナリフォーマットのプログラムを実行させることを目的としているわけですね。
ただブラウザ上で動くということはクロスプラットフォームでもあるわけで、その汎用性から注目されているようです。

WebAssemblyをUnityで扱えるようにする

さてではこのWebAssemblyをどのようにしてUnityで利用したらいいのでしょうか。
色々調べてみたところ、C#からWebAssemblyを利用できるようにする以下のGitHubプロジェクトを見つけました。今回はこれを使って行きたいと思います。

github.com

WebAssemblyを作る

上記セットアップを終えたらUnityで(C#から)WebAssemblyを扱うことができるようになります。

しかし使えるようになっても肝心のWebAssemblyのファイルがないと始まりませんね。
ということでWebAssemblyファイルを準備します。

以下のサイトで言及されていたWebサービスが手軽に生成できるのでとてもよかったです。

www.freecodecamp.org

Webサービスはこちら↓

https://mbebenita.github.io/WasmExplorer/

ちなみに上記サイトはCとC++両方でコードを書けてWebAssemblyにコンパイルできます。が、C++だとマングリングされてしまって、C#から利用する際に関数名などが分からなくなってしまうのでCでコンパイルするといいでしょう。

今回はサンプルとしてTest関数を以下のように定義してコンパイルします。

int Test(int a)
{
  return a + 123;
}

コマンドラインでCをWebAssemblyに変換する

いちおう正攻法というか、サービスを利用せずにC/C++からwasmファイルにコンパイルする方法も書いておきます。
手順についてはMozillaのMDNで解説されているのでそれに従います。

developer.mozilla.org

Emscripten環境を構築する

Emscriptenを利用してwasmファイルにコンパイルするようです。なのでまずは環境を作ります。
環境構築自体はとても簡単で、こちらのセットアップ手順を実行するだけです。

まずはGitHubからリポジトリをクローンします。

# Get the emsdk repo
$ git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
$ cd emsdk

※ Python3.6以降が必要なので、もし環境がない人は別途Pythonのセットアップが必要になります。
自分はAnacondaを使っているので、そちらのコマンドプロンプトで実行しました。

次に、必要なツールをインストールします。

以下はMacOSでの手順です。

# Download and install the latest SDK tools.
$ ./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
$ ./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
$ source ./emsdk_env.sh

自分はWindowsなので、Windowsでの手順も書いておきます。(MacOSとちょっとだけ異なります)
ドキュメントにも以下のように注釈があります。

On Windows, run emsdk instead of ./emsdk, and emsdk_env.bat instead of source ./emsdk_env.sh.

これに従って操作を書き直すと以下の手順になります。

$ emsdk install latest

$ emsdk activate latest

$ emsdk_env.bat

上記を実行するとemccコマンドが利用できるようになります。

Cでコードを書く

今回はサンプルなので簡単なコードでコンパイルしてみます。
以下のように(上記と同じ)Test関数をひとつだけ定義したcファイルを作成します。

#include <emscripten/emscripten.h>

int EMSCRIPTEN_KEEPALIVE Test(int a)
{
  return a + 123;
}

なお、EMSCRIPTEN_KEEPALIVEを関数の頭に入れておかないと、利用されていない関数はコンパイラによって削除されてしまうので注意が必要です。
このマクロを使う場合は<emscripten/emscripten.h>をインクルードする必要があります。

CコードをWebAssemblyにコンパイルする

準備ができました。あとはこれをwasmコンパイルします。コンパイルにはemccコマンドを利用します。
今回の場合、hello.cファイルを作成したので以下のようにコマンドを実行しました。

$ emcc hello.c -s WASM=1

これでwasmファイルが生成されます。

C#からWebAssemblyを読み込んで利用する

すべての準備が終わりました。あとは最初に紹介したライブラリを使って実際にwasmファイルを読み込んで実行してみましょう。

using System.Collections.Generic;
using UnityEngine;
using Wasm;
using Wasm.Interpret;

public class WebAssemblyTest : MonoBehaviour
{
    private void Start()
    {
        // wasmファイルを読み込む
        string path = Application.streamingAssetsPath + "/test.wasm";
        WasmFile file = WasmFile.ReadBinary(path);

        // importerを生成
        var importer = new PredefinedImporter();

        // wasmをインスタンス化
        ModuleInstance module = ModuleInstance.Instantiate(file, importer);

        // インスタンスから、定義済み関数の取得を試みる
        if (module.ExportedFunctions.TryGetValue("Test", out FunctionDefinition funcDef))
        {
            // 関数が見つかったらそれを実行
            IReadOnlyList<object> results = funcDef.Invoke(new object[] { 1, });
            Debug.Log(results[0]);
        }
    }
}

非常にシンプルな記述でWebAssemblyを利用できるのが分かるかと思います。
上で紹介したサービスでコンパイルしたwasmファイルをストリーミングアセット配下に起き、ランタイムで読み込んで実行するとしっかりと計算されているのが確認できます。

しっかりと引数の1123が計算されているのが分かりますね。

C側からC#で定義した関数を呼び出す

C側のコードを実行できることが確認できました。
次は逆、つまりC#側に定義されている関数を呼び出す方法を見てみます。

Cコードを以下のように変更します。

int GetParam();

int Test(int a)
{
  int param = GetParam();
  return a + 123 + param;
}

GetParamが定義されていませんが、それをTest関数内で利用しています。このGetParamC#側で用意するわけですね。

C#側で定義したものを呼び出せるようにするには以下のようにimporterに定義を設定します。

// C#側の関数の定義
private IReadOnlyList<object> GetParam(IReadOnlyList<object> args)
{
    return new object[] { 100, };
}

// ------------------

// 上記関数をimporterに設定
var importer = new PredefinedImporter();
importer.DefineFunction(
    "GetParam",
    new DelegateFunctionDefinition(
        new WasmValueType[] { },
        new [] { WasmValueType.Int32, },
        GetParam));

定義を設定するにはimporterのDefineFunctionメソッドを利用します。
第一引数にはC側で利用を想定している関数名を文字列で、第二引数にはDelegateFunctionDefinitionクラスのインスタンスを渡します。

DelegateFunctionDefinitionクラスのコンストラクタは以下のシグネチャを持ちます。

public void DefineFunction(string name, FunctionDefinition definition);

実行すると、しっかりと100が足されているのが分かるります。
これでC#側で定義したコードをC側から呼び出すことができました。

これができれば、あとはMonoBehaviour側とやり取りするためのインターフェースを定義しておけば自由にコードを実行することができるようになります。

サーバからWebAssemblyをダウンロードして利用する

最後に、サーバからバイナリを取得して実行する部分もメモしておこうと思います。
といってもやることはシンプルです。

まず、サーバにアクセスするときと同様にUnityWebRequestを使ってデータを取得します。
取得後、byte配列をストリーム経由で読み出せるようにセットアップしてWasmFileクラスのコンストラクタに渡せばOKです。

コード断片を載せておきます。

// UnityWebRequestのGetリクエストを生成
UnityWebRequest req = UnityWebRequest.Get(_url);

// 取得されたらコールバックで処理
req.SendWebRequest().completed += operation =>
{
    // メモリストリームを生成
    MemoryStream stream = new MemoryStream();

    // ストリームに、取得したbyte配列を設定
    stream.Write(req.downloadHandler.data, 0, req.downloadHandler.data.Length);
    stream.Seek(0, SeekOrigin.Begin);

    // メモリストリームから`WasmFile`オブジェクトを生成
    WasmFile file = WasmFile.ReadBinary(stream);

    // 以下は上記サンプルと同様の手順で関数を呼び出す
};

最後に

ライブラリのおかげでだいぶ簡単にWebAssemblyが利用できることが分かったかと思います。
iOS/Androidの実機でも動作することが確認できたので、ちょっとした機能の追加など、WebAssemblyを利用することで可能となることが出てくると思います。

自分は特に今、ARアプリを作っていて、さらにARならではの新しい体験を作ることがメインなのでこうした「あとから追加」の機能は色々なところで利用できそうです。

UnityでAWSのS3を扱うためのメモ

目次

概要

今回は訳合って、画像をサーバにアップロードしてそれを扱いたいなと思い、AWSのS3(Amazon Simple Storage Service)をUnity(特にモバイルプラットフォーム)から利用したくて色々ハマったのでそのメモです。

今回の目的はS3を利用して画像をアップロード、ダウンロードする方法についてのまとめです。

ドキュメントはこちら。

docs.aws.amazon.com

今回の実装にあたりこちらの記事を参考にさせていただきました。

qiita.com

実現すること

  • Unityを利用してAmazon S3に画像のアップロード、ダウンロードを行う

テスト環境

  • Unity 2020.1.14f1
  • Oculus Quest 2

フロー

  • AWS SDK for .NETをダウンロードしてインポートする
  • Identity Pool IDを取得する
  • S3ののバケットを作成する
  • IAMの設定

AWS SDK for .NETをダウンロード・インポートする

参考にした記事ではAWS Mobile SDK for Unityをインポート、と書かれていますが現在はどうやらこれはサポート外らしく、これの代わりにAWS SDK for .NETを利用する必要があります。

ダウンロードは以下の公式サイトに書かれているzip圧縮されたものをダウンロードしてきます。
(Download the following ZIP file: aws-sdk-netstandard2.0.zipと書かれている箇所。リンクはこちら

docs.aws.amazon.com

上記について公式のブログでも発表されています。

aws.amazon.com

必要なDLLをPluginsフォルダにコピー

上記zipファイルを解凍するとたくさんのDLLが出てきます。が、すべてを利用する必要はなく、自分が利用したいAWSのサービス用のDLLのみをコピーします。
ただし、AWSSDK.Core.dllだけはすべてのDLLが参照しているのでコピーが必要です。

今回はS3へ画像のアップロード・ダウンロードを行うのが目的なので以下のDLLをコピーしました。

  • AWSSDK.Core
  • AWSSDK.S3
  • AWSSDK.CognitoIdentity
  • AWSSDK.SecurityToken

依存しているDLLもコピー(依存関係の解決)

さて、実はこれだけで話は終わりません。というのもこのSDKは元々はNuGet*1)で提供されているもので、NuGet経由でインストールした場合はそれに紐づく依存関係も解決した上でインストールしてくれます。

が、今回は手動で上記DLLたちをコピーしているので手動でこの依存関係を解決しないとなりません。

このあたりについては以下の記事を参考にさせていただきました。(めちゃ助かりました)

qiita.com

依存方法の解決方法としてはMicrosoft自身が方法を記載してくれているのでそちらを参考にすることができます。

docs.microsoft.com

参考にした記事でざっくりした説明がされているので引用させていただくと、

ざっくり言うと、NuGetのパッケージ提供ページの「Dependencies」から依存関係を辿り、各パッケージのページにてパッケージをダウンロード、拡張子を.nupkgから.zipに変更して解凍するとdllが得られるのでこれを繰り返す、というものです。

ということで、これに従って依存しているDLLを探していきます。
まずはNuGetのパッケージ提供ページへ行き、「AWS SDK」と検索するとパッケージの一覧が表示されます。

まずはAWSSDK.Coreのページを見てみます。すると以下のように詳細が表示され、その中に依存が記載されている箇所があります。

赤線を引いたところがポイントです。
.NETStandard 2.0と書かれている部分が依存しているDLLの情報です。(今回のSDKが.NET Standard 2.0用のものなので)

親切にリンクが張られているのでそのまま該当ページに飛びます。

すると同様に詳細ページが開きます。

まずは右側にあるDownload packageからパッケージをダウンロードします。
そしてページ下部にはさらに依存を示す情報が載っているので、以後同様に依存しているパッケージをダウンロードします。

最終的には以下のパッケージが必要になります。

  • Microsoft.Bcl.AsyncInterfaces
  • System.Threading.Tasks.Extensions
  • System.Runtime.CompilerServices.Unsafe

ダウンロードが終わったら、記事に記載されている手順に従って拡張子を.nupkg.zipに変更した上でzipファイルを解凍します。

解凍するといくつかのファイルが展開されるので、lib/netstandard2.0配下にあるDLLを、AWS SDKと同様にコピーします。

必要なDLLをすべてコピーし終わるとエラーが消えていると思います。

ちなみに依存を解決する前には以下のようなエラーが出ていると思います。

Assembly 'Assets/Plugins/AWSSDK.Core.dll' will not be loaded due to errors:
Unable to resolve reference 'Microsoft.Bcl.AsyncInterfaces'. Is the assembly missing or incompatible with the current platform?

AWS CoreがまさにMicrosoft.Bcl.AsyncInterfacesに依存していることを示すエラーですね。

ここから先はS3側のセットアップになります。

Identity Pool IDを取得する

以下の手順に従ってIdentity Pool IDを取得します。

  • Cognito Consoleを開く
  • IDプールの管理ボタンをクリック
  • 新しいIDプールの作成ボタンをクリック
  • 任意のIDプール名を入力
  • 認証されていない ID に対してアクセスを有効にするチェックボックスをオンにする
  • 許可をクリック

上記を実行すると最後にプラットフォーム選択画面が表示されるので、そこでUnityを選びます。
すると以下のようなコードが表示されるのでコピーしておきます。(実装時に利用します)

CognitoAWSCredentials credentials = new CognitoAWSCredentials (
    "us-east-2:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", // Identity Pool ID
    RegionEndpoint.USEast2 // Region
);

バケットの作成

以下の手順に従ってバケットを作成します。

これでバケットが作成されます。

IAMの設定

  • IAM Management Console*2)を開く
  • 左のメニューからロールをクリック
  • ロール名の中からCognito_<Identity_Pool_Name>Unauth_Roleを選択
  • ポリシーをアタッチをクリック
  • AmazonS3FullAccessを付与(検索して出てきた左記のチェックボックスをオンにしてポリシーのアタッチをクリック)

最初に参考にした記事ではポリシーを作成、という手順だったのですが手順通りにしても項目が見つかりませんでした。
ただやりたいことはアクセス権の付与なので、上記の方法で動作が確認できました。

C#で実装する

以上でセットアップと準備が終わりました。あとはC#で実際のコードを書いていきます。
以下から、ひとつずつ実装を見ていきます。

Credentialのセットアップ

Identity Pool IDを取得するのところで出てきたコードを利用します。
CognitoAWSCredentialsオブジェクトを生成しますが、一度生成したら使い回せるのでAwakeなどのタイミングで生成するといいでしょう。

[SerializeField] private string _poolID = "POOL_ID";
[SerializeField] private string _bucketName = "Bucket Name";

private void Awake()
{
    _credentials = new CognitoAWSCredentials(_poolID, RegionEndpoint.USEast2);
}

ここで生成したCognitoAWSCredentialsをダウンロード・アップロード時に利用します。

画像のダウンロード

まずは画像のダウンロードから見ていきましょう。

for .NET版になってからasync/awaitが使えるようになったので簡単にコードを記述することができるようになりました。
フローは、

  1. Getリクエストオブジェクトを生成する
  2. AmazonS3Clientオブジェクトを生成する
  3. (2)のクライアントを使ってリクエストをawaitする
  4. 取得したデータをメモリに読み込む
  5. byteデータを画像化

というステップです。

コードはそこまで長くないので、上記フローを意識して見てもらえると分かるかと思います。

public async UniTask<Texture2D> DownloadImage(string fileName)
{
    GetObjectRequest request = new GetObjectRequest
    {
        BucketName = _bucketName,
        Key = fileName,
    };

    AmazonS3Client client = new AmazonS3Client(_credentials, Region);

    byte[] data = null;

    Debug.Log("Will download a texture from AWS S3.");

    GetObjectResponse result = await client.GetObjectAsync(request);

    Debug.Log("Received a response.");

    MemoryStream stream = new MemoryStream();
    result.ResponseStream.CopyTo(stream);
    data = stream.ToArray();

    Texture2D tex = new Texture2D(1, 1);
    tex.LoadImage(data);
    tex.Apply();

    return tex;
}

画像のアップロード

次に画像のアップロードです。
以下の例はメモリから読み出してアップロードするという手順になります。

ちなみに、for Unity版だとPostObjectRequestオブジェクトを利用していたのですが、これがrenameされたようです。(UploadPartRequestを使う)

フローは以下の通りです。

  1. Texture2Dをbyte配列に変換する
  2. MemoryStreamを作成し、変換したbyte配列を書き込む
  3. UploadPartリクエストオブジェクトを生成する
  4. AmazonS3Clientオブジェクトを生成する
  5. (4)のクライアントを使ってリクエストを投げる(必要があればawaitする)

というステップです。

こちらもコードはそんなに長くないのでそのまま見てもらえれば分かるかと思います。

public async UniTask<string> UploadImage(Texture2D image)
{
    string filename = "Images/" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ".png";

    AmazonS3Client client = new AmazonS3Client(_credentials, RegionEndpoint.GetBySystemName(RegionEndpoint.USEast2.SystemName));

    byte[] data = image.EncodeToPNG();

    MemoryStream stream = new MemoryStream(data.Length);
    stream.Write(data, 0, data.Length);
    stream.Seek(0, SeekOrigin.Begin);

    var request = new UploadPartRequest
    {
        BucketName = _bucketName,
        Key = filename,
        InputStream = stream,
    };

    UploadPartResponse result = await client.UploadPartAsync(request);

    Debug.Log(result);

    stream.Close();

    return filename;
}

ひとつだけ注意点があって、MemoryStreamに書き込んだあとはシーケンスを戻す必要があります。

MemoryStream stream = new MemoryStream(data.Length);
stream.Write(data, 0, data.Length);
stream.Seek(0, SeekOrigin.Begin);

この最後の行(stream.Seek(0, SeekOrigin.Begin))を実行しないと0バイトの画像がアップされてしまうので注意してください。

ファイルからアップロードする

今回はすでにメモリにあるものをアップロードするためにMemoryStreamを利用しました。が、ファイルからアップロードしたいという場合もあると思います。

その場合でも、FileStreamを作ってリクエストに設定してあげればOKです。(なので逆を返せばストリームであればなんでも渡せるってことですね)

コード断片だけ書いておきます。

FileStream stream = new FileStream("path/to/file", FileMode.Open, FileAccess.Read, FileShare.Read);

var request = new UploadPartRequest
{
    BucketName = _bucketName,
    Key = filename,
    InputStream = stream,
};

まとめ

調べてすぐに出てくるfor Unity版がすでにサポートされていないっていうのはハマりポイントでした。
それ以外にも、解説してくれている記事を参考にセットアップするも、微妙に内容が異なって「これでいいのか・・?」と不安になりつつセットアップしていくのが大変でした。

今回の記事が誰かの役に立てば幸いです。

*1:NuGetは.NET Framework用のパッケージマネージャ

*2:Identity and Access Management

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

概要

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

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

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

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

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

github.com

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

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

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

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

note.com

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

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

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

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

テクスチャの内容

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

using UnityEngine;
using UnityEngine.VFX;

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

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

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

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

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

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

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

        _colorBuffer = new Color[size * size];

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

        UpdatePositionMap();
    }

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

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

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

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

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

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

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

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

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

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

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

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

まとめ

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

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

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

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

概要

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

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

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

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

qiita.com

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

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

forpro.unity3d.jp



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

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

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

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

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

edom18.hateblo.jp

edom18.hateblo.jp

Update Particleのカーネル

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

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

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

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

ConformToSDF関数を読み解く

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

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

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

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

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

    float3 dir;

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

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

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

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

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

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

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

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

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

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

SDFファイルの中身

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

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

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

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

偏微分を用いた力の計算

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

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

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

とのこと。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

qiita.com

まとめ

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

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

その他参考にした記事

colourmath.com marupeke296.com

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

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

qiita.com



概要

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

edom18.hateblo.jp edom18.hateblo.jp


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

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

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

URP用のUnlitなシェーダ

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

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

        Pass
        {            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

            struct Attributes
            {
                float4 positionOS   : POSITION;                 
            };

            struct Varyings
            {
                float4 positionHCS  : SV_POSITION;
            };

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

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

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

SRP Batcherを有効にする

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

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

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

CBUFFER_START(UnityPerMaterial)
    half4 _BaseColor;            
CBUFFER_END

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ZERO_INITIALIZE

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

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

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

CBUFFER_START / CBUFFER_END

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

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

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

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

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

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

docs.microsoft.com

TransformObjectToHClip

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

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

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

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

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

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

まとめ

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

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

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

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

【XR】URP向けのマルチビュー対応イメージエフェクトシェーダの書き方

概要

前回書いた「URPで背景をぼかしてuGUIの背景にする」で書いたことの続編です。

edom18.hateblo.jp

具体的には、前回の実装のままでVRのマルチビュー(やSingle Pass Instanced)に変更すると正常に描画されないという問題があったのでそれへの対応方法がメインの内容となります。

マルチビューで動いているかどうか伝わらないですが(w)、動作した動画をアップしました。

今回の問題を対処したものはGitHubリポジトリにマージ済みです。

github.com



マルチビューに対応する

イメージエフェクト(ブラー)については前回の記事とほぼ同じです。
それをベースにいくつかの部分をマルチビュー対応していきます。

なお、マルチビューなどのステレオレンダリングについては凹みさんの以下の記事が超絶詳しく解説してくれているので興味がある方はそちらを参考にしてみてください。

tips.hecomi.com

そもそもマルチビューとは

マルチビューとは一言で言うとOpenGLが持つOVR_multiviewという拡張機能です。
VRの場合、両目にレンダリングする必要があるためどうしても処理負荷が高くなりがちです。

そこで様々な方法が考え出されました。(それらについては前述の凹みさんの記事を参照ください)
それに合わせてGPUベンダー側も要望に答える形で新しい機能を追加したりしています。

このOpenGL拡張機能もそうした新機能を使うためのものです。
自分もまだ正確に理解しきれてはいないのですが、VRの両目レンダリングの負荷を下げるために、一度だけレンダリングのコマンドを送信すると、それをよしなに複製して両目分にレンダリングしてくれる、というような機能です。

Oculusのドキュメントから引用すると以下のように説明されています。

マルチビューを有効化すると、オブジェクトは一度左のアイバッファーにレンダリングされた後、頂点位置と視覚依存変数(反射など)に適切な変更が加えられて、自動的に右のバッファーに複製されます。

また、OculusのWebGL版のドキュメントでは以下のように説明されています。

マルチビュー拡張機能では、ドローコールがテクスチャー配列の対応する各エレメントにインスタンス化されます。頂点プログラムは、新しいViewID変数を使用して、ビューごとの値(通常は頂点位置と反射などの視覚依存変数)を計算します。

ドローコールがテクスチャ配列ごと(つまり両目のふたつ)にインスタンス化されることで実現しているようですね。

そしてこれを実現しているのがレンダーターゲットアレイと呼ばれる、レンダーターゲット(ビュー)を配列にしたものです。
なので「マルチビュー」なんですね。

そしてこの「レンダーターゲットアレイ」というのが今回の修正のキモです。
どういうことかと言うと、前回の実装ではレンダーターゲットアレイではなく、あくまで片目用の通常のレンダリングにのみ対応した書き方をしていました。
(マルチパスの場合は片目ずつそれぞれレンダリングしてくれていたので問題にならなかった)

だからマルチビューにした途端に正常に動かなくなっていたというわけです。
しかし、マルチビューかどうかを判定して色々処理を書くのはとても骨が折れます。

そこでUnityは、どちらの設定であっても正しく処理を行えるようにするためのマクロをたくさん用意してくれています。
今回の修正は主に、それらマクロを使ってどう記述したらいいかということの説明になります。

その他、マルチビューについての解説は以下の記事を参照ください。

blogs.unity3d.com

マルチビュー対応マクロを使う

前述のように、それぞれの処理はマクロを使うことでマルチビューでもそうでなくても正常に動作するコードを書くことができます。

それほど長いコードではないので実際に適用済みのコードをまず貼ってしまいましょう。

Shader "Custom/BlurEffect_Adapted"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
    }

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

    static const int samplingCount = 10;

    TEXTURE2D_X(_MainTex);
    SAMPLER(sampler_MainTex);
    uniform half4 _Offsets;
    uniform half _Weights[samplingCount];

    struct appdata
    {
        half4 pos : POSITION;
        half2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };

    struct v2f
    {
        half4 pos : SV_POSITION;
        half2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
        UNITY_VERTEX_OUTPUT_STEREO
    };

    v2f vert(appdata v)
    {
        v2f o;

        UNITY_SETUP_INSTANCE_ID(v);
        UNITY_TRANSFER_INSTANCE_ID(v, o);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

        o.pos = mul(unity_MatrixVP, mul(unity_ObjectToWorld, half4(v.pos.xyz, 1.0h)));
        o.uv = UnityStereoTransformScreenSpaceTex(v.uv);

        return o;
    }

    half4 frag(v2f i) : SV_Target
    {
        UNITY_SETUP_INSTANCE_ID(i);
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
        
        half4 col = 0;

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

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

        half3 grad1 = half3(1.0, 0.95, 0.98);
        half3 grad2 = half3(0.95, 0.95, 1.0);
        half3 grad = lerp(grad1, grad2, i.uv.y);

        col.rgb *= grad;
        col *= 1.15;

        return col;
    }
    ENDHLSL

    SubShader
    {
        Pass
        {
            ZTest Off
            ZWrite Off
            Cull Back
            
            Fog
            {
                Mode Off
            }

            HLSLPROGRAM
            #pragma fragmentoption ARB_precision_hint_fastest
            #pragma vertex vert
            #pragma fragment frag
            ENDHLSL
        }
    }
}

ビルトインのレンダーパイプライン向けにイメージエフェクトを書かれたことがある人であればちょっとした違いに気付くかと思います。

まず大きな違いはHLSLで記述することです。なのでCGPROGRAMではなくHLSLPROGRAMで始まっているのが分かるかと思います。

そしてこれから紹介するマクロは以下の.hlslファイルに定義されています。

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

これをインクルードすることで以下のマクロたちが使えるようになります。

マクロを使ってテクスチャを宣言する

ではさっそく上から見ていきましょう。
まずはテクスチャの宣言です。

前述したように、マルチビューでない場合は通常のテクスチャで、マルチビューの場合は配列として処理を行う必要があります。
ということで、それを設定に応じてよしなにしてくれるマクロを使って書くと以下のようになります。

TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);

TEXTURE2D_X_Xが次元を表していると考えると覚えやすいかと思います。
そして以前は必要なかったサンプラの宣言も合わせて行っています。

修正前は以下のようになっていました。

sampler2D _MainTex;

マクロを使ってテクスチャからフェッチする

次はテクスチャの使い方です。

まず、UVの座標空間が若干異なるため、それを変換するための関数を実行して変換してやります。

// そのままフラグメントシェーダに渡すのではなく、関数を通して変換する
o.uv = UnityStereoTransformScreenSpaceTex(v.uv);

テクスチャフェッチは以下のようにマクロを使います。

half4 col = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, i.uv);

使う場合も同様にSAMPLE_TEXTURE2D_X_Xがついていますね。
そして第2引数にサンプラを指定します。それ以外の引数は普段見るものと違いはありません。

ビューIDを適切に取り扱う

実は上記マクロだけでは正常にレンダリングされません。

というのも、マルチビューは配列を利用して処理を最適化するものだと説明しました。
配列ということは「どちらのテクスチャにアクセスしたらいいか」という情報がなければなりません。

そしてそのセットアップはまた別のマクロを使って行います。
セットアップは構造体の宣言に手を加え、適切に初期化を行う必要があります。

ビューIDを追加するマクロ

新しくなった構造体の宣言は以下のようになります。

struct appdata
{
    half4 pos : POSITION;
    half2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
    half4 pos : SV_POSITION;
    half2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

appdata、つまり頂点シェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_IDを追加し、v2f、つまりフラグメントシェーダの入力にはUNITY_VERTEX_INPUT_INSTANCE_IDUNITY_VERTEX_OUTPUT_STEREOのふたつを追加します。

マクロの中身については後述しますが、こうすることで適切にインデックスを渡すことができるようになります。

ビューIDの初期化

続いてシェーダ関数内で値を適切に初期化します。具体的には以下のようにマクロを追加します。

v2f vert(appdata v)
{
    v2f o;

    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_TRANSFER_INSTANCE_ID(v, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    // 以下省略
}

頂点シェーダ関数の冒頭でマクロを利用して初期化を行います。
次はフラグメントシェーダ。

half4 frag(v2f i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
    // 以下省略
}

処理についてはマクロを追加するだけなのでとても簡単ですね。

マクロの役割

さて、ではこれらマクロはなにをしてくれているのでしょうか。
先にざっくり説明してしまうと、前述した配列へ適切にアクセスできるようにインデックスを処理する、ということになります。

ということでそれぞれのマクロを紐解いていきましょう。

TEXTURE2D_XとSAMPLER

これはテクスチャの宣言時に用いるマクロです。これがどう展開されるか見ていきましょう。

宣言では以下のマクロによって分岐が発生します。

#if defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)

見ての通り、マルチビュー(かGPUインスタンシング)がオンの場合に異なる挙動になります。

それぞれの定義を見ていくと最終的に以下のようにそれぞれ展開されることが分かります。

// 通常
#define TEXTURE2D(textureName)  Texture2D textureName

// マルチビュー
#define TEXTURE2D_ARRAY(textureName) Texture2DArray textureName

通常時はただのTexture2Dとして宣言され、マルチビューの場合はTexture2DArrayとして宣言されるのが分かりました。

続いてSAMPLERです。こちらは素直に以下に展開されます。

#define SAMPLER(samplerName) SamplerState samplerName

SAMPLE_TEXTURE2D_X

次は実際に利用する際のマクロです。これもどう展開されるか見てみましょう。
こちらも同様にマルチビューか否かによって分岐されます。

分岐後はそれぞれ以下のように展開されます。

// 通常
#define SAMPLE_TEXTURE2D(textureName, samplerName, coord2) textureName.Sample(samplerName, coord2)

// マルチビュー
// 以下を経由して、
#define SAMPLE_TEXTURE2D_X(textureName, samplerName, coord2) SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, SLICE_ARRAY_INDEX)

// 最終的にこう展開される
#define SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, index) textureName.Sample(samplerName, float3(coord2, index))

こちらはマルチビューの場合は少しだけ複雑です。とはいえ、配列へアクセスするための添字を追加してアクセスしている部分だけが異なりますね。
そしてその添字はSLICE_ARRAY_INDEXというマクロによってさらに展開されます。

SLICE_ARRAY_INDEXでテクスチャ配列の添字を得る

SLICE_ARRAY_INDEXは以下のように定義されています。

#define SLICE_ARRAY_INDEX   unity_StereoEyeIndex

XRっぽい記述が出てきました。次に説明するマクロによってこのインデックスが解決されます。
ここで大事な点は、マクロを利用することでテクスチャなのかテクスチャ配列なのかを気にせずに透過的に宣言が行えるという点です。

UNITY_VERTEX_OUTPUT_STEREO

構造体のところで使用したマクロです。名前からも分かるようにXR関連のレンダリングに関する設定になります。
UNITY_STEREO_MULTIVIEW_ENABLEDが定義されている場合に以下のように展開されます。

#define DEFAULT_UNITY_VERTEX_OUTPUT_STEREO float stereoTargetEyeIndexAsBlendIdx0 : BLENDWEIGHT0;
#define DEFAULT_UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) unity_StereoEyeIndex = (uint) input.stereoTargetEyeIndexAsBlendIdx0;

unity_StereoEyeIndexSLICE_ARRAY_INDEXマクロが展開されたときに使われているものでした。ここでまさに定義され、値が設定されているというわけです。

UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)

頂点シェーダで利用されているマクロです。これは、適切にoutput.stereoTargetEyeIndexAsBlendIdx0の値を設定するために用いられます。

展開されたあとの状態を見てみましょう。

#define DEFAULT_UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output) output.stereoTargetEyeIndexAsBlendIdx0 = unity_StereoEyeIndices[unity_StereoEyeIndex].x;

フラグメントシェーダに渡す構造体に値が設定されているのが分かるかと思います。利用されているのは前述のunity_StereoEyeIndexですね。
こうしてマクロを通して匠に値が設定されていくわけです。

なお、マルチビューではない場合はマクロは空になっているのでなにも展開されません。

UNITY_SETUP_INSTANCE_ID / UNITY_VERTEX_INPUT_INSTANCE_ID / UNITY_TRANSFER_INSTANCE_ID

最後にインスタンスIDについて見ていきましょう。
これはGPUインスタンシングで利用されるものです。
(なのでマルチビューでは使用されません)

// これは構造体にインスタンスIDを宣言するもの
#define DEFAULT_UNITY_VERTEX_INPUT_INSTANCE_ID uint instanceID : SV_InstanceID;
// これは頂点シェーダからフラグメントシェーダへインスタンスIDを渡すための処理
#define UNITY_TRANSFER_INSTANCE_ID(input, output)   output.instanceID = UNITY_GET_INSTANCE_ID(input)
// 頂点シェーダ内でインスタンスIDをセットアップする
#define DEFAULT_UNITY_SETUP_INSTANCE_ID(input) { UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input));}

最後のマクロだけ関数呼び出しが入ります。続けて関数UnitySetupInstanceIDも見てみましょう。

void UnitySetupInstanceID(uint inputInstanceID)
{
    #ifdef UNITY_STEREO_INSTANCING_ENABLED
        #if !defined(SHADEROPTIONS_XR_MAX_VIEWS) || SHADEROPTIONS_XR_MAX_VIEWS <= 2
            #if defined(SHADER_API_GLES3)
                // We must calculate the stereo eye index differently for GLES3
                // because otherwise,  the unity shader compiler will emit a bitfieldInsert function.
                // bitfieldInsert requires support for glsl version 400 or later.  Therefore the
                // generated glsl code will fail to compile on lower end devices.  By changing the
                // way we calculate the stereo eye index,  we can help the shader compiler to avoid
                // emitting the bitfieldInsert function and thereby increase the number of devices we
                // can run stereo instancing on.
                unity_StereoEyeIndex = round(fmod(inputInstanceID, 2.0));
                unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1);
            #else
                // stereo eye index is automatically figured out from the instance ID
                unity_StereoEyeIndex = inputInstanceID & 0x01;
                unity_InstanceID = unity_BaseInstanceID + (inputInstanceID >> 1);
            #endif
        #else
            unity_StereoEyeIndex = inputInstanceID % _XRViewCount;
            unity_InstanceID = unity_BaseInstanceID + (inputInstanceID / _XRViewCount);
        #endif
    #else
        unity_InstanceID = inputInstanceID + unity_BaseInstanceID;
    #endif
}

だいぶ長いですね。ただ#if defined(SHADER_API_GLES3)のほうはコメントにも書かれている通り、GLSL3以下のための回避策のようです。

ここで行っていることはそうしたデバイスの違いを吸収し、適切にunity_StereoEyeIndexunity_InstanceIDを設定することです。


長々とマクロを見てきましたが、行っていることを一言で言ってしまえば、マルチビュー(とGPUインスタンシング)の場合とそれ以外でエラーが出ないようにセットアップしてくれている、ということです。

そして大事な点はマルチビューなどの場合では「テクスチャ配列」を介して処理が行われるということです。
これを行わないと適切に描画されなくなってしまいます。

以上が、マルチビュー対応のためのシェーダの書き方でした。

ScriptableRenderPassでRenderTextureを生成する際の注意点

今回の修正の大半はシェーダでした。が、ひとつだけC#側でも対応しないとならない箇所があります。
それがRenderTextureDescriptorの取得箇所です。

とはいえコードはめちゃ短いので見てもらうほうが早いでしょう。

RenderTextureDescriptor descriptor = XRSettings.enabled ? XRSettings.eyeTextureDesc : camData.cameraTargetDescriptor;

XRSettings.enabledを見るとXRかどうかが判断できます。そしてその場合にはXRSettings.eyeTextureDescからdescriptorを取得することで適切なRenderTextureを得られるというわけです。

ちなみにdescriptorは「記述子」と訳されます。これは「どんなRenderTextureなのかを説明するもの」と考えるといいでしょう。
そしてそれを元にRenderTextureが取得されるため、VRのマルチビューの場合はTextureArrayの形でRenderTextureが取得されるというわけです。

参考にした記事