Unityでアバターを外部からいい感じにロードしたい

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

私の前回の記事では、Unityにおける画像のランタイムでのロード方法を紹介しました。

synamon.hatenablog.com

Unityは画像に限らずRuntimeで外部リソースを取り込もうとするとひと手間必要になりますよね。

今回は、3DモデルをRuntimeでHumanoidとしてロードしアバターとして利用できるようにしたい、というお話になります。

Clusterをはじめ最近のメタバース系サービスでは、ユーザーが持っているアバターをアップロードして利用できるというのも一般的になりつつあると思います。

その場合には当然ユーザーのアバターのデータはアプリ本体に含めることはできないため、Runtimeで外部から読み込んで利用することになります。

VRChatのようにAssetBundle × 独自のSDKを組むという手法もありますが、開発するにも利用するにも難易度が高いため、一般向けに提供するには少しハードルが高いです。

一方、アバター用の3DモデルフォーマットであるVRMや汎用3DモデルフォーマットのFBX、glTFなどを利用するとアバターの再利用性も良いですし、 開発する観点でも容易になるため、アバターをこれらのフォーマットのバイナリーファイルとしてRuntimeで読み込む手法が取られることが多いと思います。

ただしアバターとして利用するためにはただ3Dモデルを読み込むだけでは不十分で、ユーザーの操作に応じたアニメーションができるようにセットアップしてあげる必要があります。

本記事では、外部からアバターの3Dモデルのファイルを取り込みアバターとしてHumanoid Boneによるアニメーションができる状態にすることをゴールに、 基本的な流れや利用できるライブラリ、フォーマット別の注意点などを紹介します。

全体の処理の流れ

想定される基本的な処理の流れは以下になります。

  1. アバターに使用したい3Dモデルのファイルをバイナリーデータとして読み込む
  2. フォーマット別にデコードする
  3. HumanoidとしてセットアップされたGameObjectにする
  4. Boneを使ったHumanoidアニメーションでアバターが動かせるようになる

1では具体的にどこからデータを読み込むのかは問いません。

ローカルストレージやStreamingAssetsに入れてもいいですし、URLで取得しても構いませんし、テキストデータとしてAddressableにしても大丈夫です*1

最終的には byte[]Stream の形で取得する形になるでしょう。

2ではファイルフォーマット毎にデコード方法が異なりますが、Unityは標準ではRuntimeでの3Dモデルファイルの読み込みには対応していないため、各フォーマットに対応したライブラリを使用することになります。

対応するフォーマットの種類に関してはサービスの方針次第ではありますが、本記事ではアバターに使用することを想定しているVRM、海外でも比較的広く使われている汎用フォーマットであるglTFFBXの3つを紹介します。

3ではUnityのAvatarというHumanoidアニメーションが手軽にできる仕組みに乗せることで、4のBoneを使ったHumanoidアニメーションができるようにセットアップします。

docs.unity3d.com

4はHumanoidアニメーションで実際に動かす部分ですが、Animator Controller を使ってアニメーションをさせたり、あるいはIK(Inverse Kinematics)を使ってインタラクティブに動かすなどサービスによってさまざまな手法が取れます。

アバターとして利用するためには、3Dモデルを読み込むだけではなく、それをアニメーションさせるためのセットアップが必要になるのがポイントです。

サービス仕様によって異なる部分の大きい1と4は他の記事でも紹介があると思いますので省略させていただき、2と3のステップに関してフォーマット別に詳しく紹介します。

VRM

vrm-consortium.org

「VRM」はVRアプリケーション向けの人型3Dアバター(3Dモデル)データを扱うためのファイルフォーマットです。

拡張子は .vrm で、Unity向けのプラグインも提供されています。

github.com

VRMは説明にもあるように人型(Humanoid)であることを前提に設計されており、更にVRで使用するために必要な基本仕様を備えています。

ですので基本的には2~3の流れをフォーマットの仕様としてライブラリ側で行ってくれるため、難しく考えることなくアバターとして使用できます。

2のバイナリーデータのRuntimeでのロードにも対応していますし、4は生成されたPrefab(GameObject)にアタッチされている Animator などを用いれば実現できるでしょう。

ライブラリの使い方はバージョンによってAPI仕様が少し異なるため、公式のドキュメントを参照してください。

vrm-c.github.io

VRMを採用する上で注意すべきこと

VRMを採用する際には二つ注意点があります。

一つはVRMがまだ日本内での使用が主で、海外ではまだ普及していない点です。

VRMコンソーシアムの動きもありますし、海外での他のフォーマットの使用例もあまり聞かないため今後に期待したいですね。

prtimes.jp

とはいえ国内ではVroidをはじめ製作・利用のプラットフォームも出てきているため、サービスのターゲット次第ではVRMのみ対応しても十分な場合もあるでしょう。

vroid.com

二つ目が、標準ではUnityのURPに対応していない点です。

MetaQuest2を含むモバイル環境ではグラフィックの負荷軽減は可能な限り行いたいため、URPを採用したいケースも多いと思います。

VRMで使用可能なTool ShaderはMToonというものなのですが、これが通常のRender Pipeline前提で作られているものになっています。

一応自前で拡張可能なAPIも用意されているのですが、Shaderの仕様を理解したうえで拡張する必要があるため少しハードルが高いです。

vrm-c.github.io

それに近いことをされているのは調べるといくつか出てきますので、参考になるかと思います。

github.com

VRMの発起人であるVirtual CastがURP対応をするという発表もあったため、少し待てば正式に対応があるかもしれません。

Humanoidとしてのセットアップを仕様としてしてくれる以外にも、詳細なメタデータ、LipSyncの定義など開発者目線では総じて扱いやすいフォーマットではあるため、コメントした二点を除けばVRM対応はそれほど苦戦することはないかと思います。

glTF

www.khronos.org

glTF™ is a royalty-free specification for the efficient transmission and loading of 3D scenes and models by engines and applications. glTF minimizes the size of 3D assets, and the runtime processing needed to unpack and use them. glTF defines an extensible, publishing format that streamlines authoring workflows and interactive services by enabling the interoperable use of 3D content across the industry.

3Dモデルの標準規格として作成されているglTFはJsonベースの汎用フォーマットで、Runtimeで使用することを想定しています。

Json形式の拡張子は .gltf、バイナリー形式の拡張子は .glbです。

あくまで汎用フォーマットであるのでHumanoidの考慮やアバター向けの規格はなく、3のHumanoidとしてのセットアップをちゃんと対応する必要があります。

ちなみに先に紹介したVRMはglTF(2.0)をベースに拡張したもので、glTFの拡張可能な領域にアバター向けの規格を入れているという形になります。

Ready Player Meをはじめ海外のアバター系サービスではglTFの出力に対応しているものも多く、日本以外のユーザー向けにサービスを提供することを想定している場合はVRM以外のフォーマットも候補になるでしょう。

readyplayer.me

NTF系のサービスもVRMかglTFのどちらかが多い印象です。

Unity向けのglTFライブラリはいくつかあります。

github.com

github.com

UniGLFTはUniVRMにも組み込まれています。

ライブラリを選定する上で気にすべき観点は以下になります。

  • Runtimeでの読み込みに対応しているか?(非同期のAPIがあるのがベスト)
  • URPなどに対応しているか?(使用しない場合は気にしないでOK)
  • Humanoidのセットアップができるか?

URPの対応はglTFだと基本的には Standard Shader → Universal Render Pipeline/Lit Shaderの変換なので難しくないでしょう。

しかし今回のアバター用途では特に3つ目の対応を自前で行うのは大変になります。

通常Unityで事前に3Dモデルをアセットとして読み込む場合には、Editor上でAvatarのセットアップをすることができます。

docs.unity3d.com

しかしこの機能はEditor上でしか利用できないため、Runtimeではこれに相当する作業をスクリプト上でする必要があります。

ですのでここの処理を自前で頑張ってやることを覚悟していたのですが、実はTriLibという様々な3Dモデルをロードできるアセットを使うと、Boneの命名規則の設定をするだけでセットアップをすることができます。

https://assetstore.unity.com/packages/tools/modeling/trilib-2-model-loading-package-157548?locale=ja-JP

おまけにURPにも対応しているので、有料ではありますがTriLibを買ってしまえばそれほど苦労することなくglTFファイルをアバターとして利用できます。

TriLibでのHumanoidのセットアップ方法

TriLibを使う場合のHumanoid向けのセットアップ方法も簡単にですが紹介しておきます。

3Dモデルのファイルをロードする際に、AssetLoaderOptions という ScriptableObject を指定することで、ロード時の処理の細かなカスタマイズをすることができます。

その設定項目の中でHumanoid向けの設定のみ紹介します。

  • Rig > Animation TypeHumanoid に設定する
  • Rig > Avatar Definition は任意
    • Create From This Model にすると、下記のMapperを使って動的に Avatar を生成します
    • Copy From Other Avatar にすると、既に作成済みの Avatar を利用することができます
  • Rig > Humanoid Avatar Mapper を指定する
    • Avatar のHumanoid Boneの設定方法を指定できます
  • Rig > Root Bone Mapper を指定する
    • 3DモデルのTransformの中から、BoneのRootとなるオブジェクトの検索方法を指定できます

Humanoid Avatar Mapperというのが重要で、AvatarのEditor上でのセットアップに相当する操作を、Humanoid Boneと3DモデルのBoneのMappingを、Boneの名前の規則性をベースに自動で行うことができるようになっています。

ただしこれは使用する3DモデルのBoneの作り方に依存する部分もあるため、調整が必要になる場面もあるでしょう。

とはいえ下2つのMapperはサンプルも用意されていて、それらを使えば基本的には問題なくロードできるため、それほど難易度は高くありません。

  • Humanoid Avatar Mapper
    • By Name Humanoid Avatar Mapper
    • Mixamo And Biped By Name Humanoid Avatar Mapper(おすすめ)
  • Root Bone Mapper
    • By Name Root Bone Mapper
    • By Bones Root Bone Mapper

これらは Create > TriLib > Mappers > Humanoid / Bone から ScriptableObject のインスタンスを簡単に作成できます。

Humanoidのセットアップに成功すると、ロード後に生成される GameObject にアタッチされている AnimatorAvatar がセットされており、この Animator を使用してアニメーションさせることができます。

TriLibでのURPの使用方法

TriLibのMaterialの生成時にURPを使用する方法は、上記の Asset Loader Options で設定できます。

Materials > Material MappersUniversalPRMaterialMapperScriptableObject のインスタンスを指定してください。

FBX

www.autodesk.com

Adaptable file format for 3D animation software FBX® data exchange technology is a 3D asset exchange format that facilitates higher-fidelity data exchange between 3ds Max, Maya, MotionBuilder, Mudbox and other propriety and third-party software.

拡張子は .fbx ですが、ASCII形式とバイナリー形式があります。

FBXはAutodesk社の策定しているフォーマットですが、同じくAutodesk社が開発しているMayaや3ds Maxなどの3Dモデリングソフトのシェアが高いこともあり、FBXは広く使われているフォーマットです。

glTFと同じく汎用3Dフォーマットのため、Humanoidとしてのセットアップが別途必要になります。

UnityはFBXのEditorでのロードは対応していますがRuntimeでのロードには対応していないため、やはりライブラリを使用することになります。

FBXをインポートできるライブラリも調べるといくつか出てきますが、glTFのところで紹介したTriLibがFBXにも対応しているためTriLibを使うのがおすすめです。

Humanoidのセットアップ方法やURPの対応方法もまったく同じですので、glTFの欄を参照してください。

おわりに

以上の紹介した内容で、

  • VRM
  • glTF
  • FBX

の3つのフォーマットの3Dモデルファイルを、

  • UniVRM
  • TriLib

の2つのライブラリを使い分けることで、Runtimeで3Dモデルをロードし、HumanoidとしてセットアップすることでHumanoid Boneによるアニメーションができる状態にすることができます。

ライブラリの使い分けが少し手間な部分ではありますが、ファイルのbyte[] or Stream から最終的にHumanoidとしてセットアップした GameObject を作成して Animator でアニメーションできる、という入出力の形式は共通のため、仕組みを作ってしまえば広いフォーマットに対応したアバター読み込み機能が作れます。

実際にはこれ以外にもファイルのリソース管理やキャッシュ、URP対応のToonShaderのカスタマイズ、アニメーションのさせ方やIKの調整、マルチプレイでのネットワーク同期、パフォーマンス調整などなど、アバターをアプリケーションに組み込むためには考えるべきことがたくさんあると思います。

とはいえ基本的なところは今回の範囲で抑えられると思いますので、アバターをRuntimeで外部からロードして使用したい方の参考になれば幸いです。

*アイキャッチのVRMはVroid HumのAvatar_Sample_Aを使用させていただきました。

hub.vroid.com

*1:拡張子を無理やり.txtにしてTextAssetとしてロードし、TextAsset.bytesでバイナリーデータを引っこ抜く、という裏技が一応あります

StreamDeckのプラグインを作ってみる

はじめに

エンジニアの松原です。趣味のガジェット漁りで以前 StreamDeck を購入しました。エンジニアとして、デバイスに関して何かできないか調べていたところ、このデバイスではプラグインを自作できるようで、ソースコードにhtmlとJavascriptが使われているようでした。これらはフロントエンド開発でよく扱う言語のため、これまで培った経験を活用して今回自作のプラグインを作ることを記事にしてみました。

公式サイトのサンプルコードGitHubに上がっていたので、今回の記事ではこちらを参考にしつつ、自分なりにプラグイン開発について整理してみました。

github.com

プラグインの構造について

プラグイン開発をする前に、まずはカスタムプラグインがどのように実行されるかざっくり調べてみました。

プラグインのアーキテクチャ

StreamDeckの公式サイトにプラグインの構造について解説がありました。

developer.elgato.com

The Stream Deck software loads all the custom plugins when the application starts. Websocket APIs allow bidirectional communication between the plugins and the Stream Deck application using JSON.

自作のプラグインを使う場合、WebSocket経由でStreamDeck本体のアプリケーションと通信できるみたいですね。図で整理すると以下のような感じでしょうか。

それぞれスタンドアロンのアプリケーションとして動作させるのであれば、WebSocketが扱える環境ならどの言語環境でも利用できそうですが、もうちょっと調べてみる必要がありました(記事の後半で解説しています)。

プラグインの実行経路

StreamDeckの公式サイトにプラグインのマニフェストについて解説がありました。

そのうちmanifest.jsonについての記載があったので、調べたところ CodePath の記述でエントリーポイントとなるhtmlファイルを指定しているみたいです。

CodePath Required The relative path to the HTML/binary file containing the plugin code.

{
  "Actions": ..., 
  "SDKVersion": 2,
  "Author": "Elgato", 
  "CodePath": "code.html", 
  "Description": "This lets you display the number of times you pressed on the key.", 
  "Name": "Counter", 
  "Icon": "pluginIcon", 
  "URL": "https://www.elgato.com/gaming/stream-deck", 
  "Version": "1.2.0",
  "OS": ...,
  "Software": ...

カスタムプラグインはWindowsの場合、 [User Home Directory]/AppData/Roaming/Elgato/StreamDeck/Plugins に配置されるようです。

StreamDeck本体のアプリケーション(StreamDeck.exe)の実行時にこのディレクトリに入っている各manifest.jsonに書かれている情報を利用してプラグインが実行されるようです。アプリケーション起動時にのみプラグインをチェックするようなので、プラグインのソースコードの変更を反映したい場合はStreamDeck本体のアプリケーションを立ち上げなおす必要がありそうです。

Javascriptの実行経路

エントリーポイントになっているhtmlファイル内に書かれているJavascriptですが、通常のWebアプリケーション開発ではイベント処理である onloadイベントなどに紐づけて自動的に処理を実行する仕組みがあるのですが、どうやらその仕組みを使っていないようです(以下は公式のプラグインサンプルのNumberDisplayのhtmlファイルです)。

github.com

htmlファイル内に記載されているJavascriptのコードを実行する方法についてはStreamDeckの公式サイトのRegistration Procedureに記載がありました。

developer.elgato.com

for Javascript plugins, its connectElgatoStreamDeckSocket() is called with several parameters.

グローバルスコープに connectElgatoStreamDeckSocket() という名前の関数を置いておけば、StreamDeck本体側でその関数を実行するようです。

function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo) {
...
}

StreamDeckのイベントをNode.jsにブリッジするプラグインを作る

プラグイン側のコード

StreamDeckのイベントをNode.js側でハンドリングするために、Node.jsのサービス側で別のWebSocketサーバーを立てて、そこにイベントを送るプラグインを書いてみます。 少し長いですが、プラグインのhtmlファイルは以下のようなコードになります。ローカルネットワークのポート40000番のWebSocketサーバーに対してStreamDeckのイベントをそのまま渡しています。

<!DOCTYPE HTML>
<html>

<head>
  <title>com.blkcatman.websocketbridge</title>
  <meta charset="utf-8" />
</head>

<body>
  <script>
    var bridgeEnabled = false;
    var bridge = null 
    const connectToBridge = () => {
      bridge = new WebSocket("ws://127.0.0.1:40000");
      bridge.onopen = () => bridgeEnabled = true
      bridge.onclose = () => bridgeEnabled = false;
    };

    const localAction = {
      onKeyDown: function (context, settings, coordinates, userDesiredState) {},

      onKeyUp: function (context, settings, coordinates, userDesiredState) {
        if (bridgeEnabled) {
          bridge.send(JSON.stringify({
            settings,
            coordinates
          }));
        } else {
          connectToBridge();
        }
      },

      onWillAppear: function (context, settings, coordinates) {}
    };

    function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo) {
      connectToBridge();
      let websocket = new WebSocket("ws://127.0.0.1:" + inPort);

      websocket.onopen = () =>  websocket.send(JSON.stringify({
        "event": inRegisterEvent,
        "uuid": inPluginUUID
      }));
      websocket.onclose = () => {};

      websocket.onmessage = (evt) => {
        var jsonObj = JSON.parse(evt.data);
        var event = jsonObj['event'];
        var context = jsonObj['context'];
        var pl = jsonObj['payload'] || {};

        if (event == "keyDown") {
          localAction.onKeyDown(context, pl.settings, pl.coordinates, pl.userDesiredState);
        }
        else if (event == "keyUp") {
          localAction.onKeyUp(context, pl.settings, pl.coordinates, pl.userDesiredState);
        }
        else if (event == "willAppear") {
          localAction.onWillAppear(context, pl.settings, pl.coordinates);
        }
      };
    };
  </script>

</body>
</html>

Node.js側のコード

Node.js側のコードは以下のようなコードになります。40000番ポートで接続を待っており、クライアントからメッセージがやってきた時にそのメッセージをコンソールログに出力しています。

const WebSocketServer = require('ws').Server;

const wss = new WebSocketServer({port: 40000});

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const jsonObj = JSON.parse(message);
    if (jsonObj != null) {
      console.log(jsonObj);
    }
  });
});

プラグインをStreamDeckのアクションにバインドする

作ったプラグインを [User Home Directory]/AppData/Roaming/Elgato/StreamDeck/Plugins に配置し、StreamDeck.exeを再起動します。 再起動後、プラグインをStreamDeckのアクションにバインドします。ドラッグ&ドロップでプラグインを選んで設定します。

できたもの

Node.jsのサービスを実行し、StreamDeck本体のボタンをポチポチした結果が以下になります。

以下はNode.jsアプリケーション側のコンソールログになります。今回はプラグイン側でイベント内容の詳細を指定していないため、アクションの座標値のみが取得できています。 座標値については 公式ドキュメントのアーキテクチャ解説のCoordinatesを見ると対応関係が分かるかと思います。

{ settings: {}, coordinates: { column: 2, row: 1 } }
{ settings: {}, coordinates: { column: 1, row: 1 } }
{ settings: {}, coordinates: { column: 1, row: 1 } }
{ settings: {}, coordinates: { column: 0, row: 1 } }
{ settings: {}, coordinates: { column: 0, row: 2 } }
{ settings: {}, coordinates: { column: 1, row: 2 } }
{ settings: {}, coordinates: { column: 1, row: 2 } }
{ settings: {}, coordinates: { column: 2, row: 2 } }
{ settings: {}, coordinates: { column: 3, row: 2 } }
{ settings: {}, coordinates: { column: 4, row: 2 } }
{ settings: {}, coordinates: { column: 4, row: 0 } }

まとめ

この記事ではStreamDeckのプラグインを自作して、Node.jsのサービス側でStreamDeckのイベントを受け取る方法について紹介しました。
次回以降はNode.js側でさらに応用することを考えるか、ネイティブのプラグインの作り方について記事にできればと思います。

NFTなんもわからんのでとりあえず試してみた②〜Hardhatでテスト実装編〜

こんにちは、エンジニアの黒岩(@kro96_xr)です。 バックエンドを中心にフロントエンドやらインフラやら色々担当しています。

前回自分の記事ではブラウザ上で動作するRemix IDEを使ってコントラクトの実装を試してみました。

synamon.hatenablog.com

今回はその続編として、Hardhatを使ったコントラクトのテスト周りについて書いていきたいと思います。

Hardhatとは

Hardhatとは公式サイトにあるOverviewの言葉を借りると「Ethereumソフトウェアのコンパイル、デプロイ、テスト、およびデバッグを行うための開発環境」とのことです。Ethereumソフトウェア=Solidityで実装されたコントラクトという感じでしょうか。

また、「Hardhatには、開発用に設計されたローカルなEthereumネットワークであるHardhat Networkが内蔵されている」ため、ローカルでのテストを行うことができます。

検証環境

  • OS
    • macOS Big Sur 11.4 (Apple M1)
  • Node.js
    • v16.13.2
  • npm
    • 8.1.2

インストール

新しいディレクトリを作りインストールします。詳しくは公式をご覧ください。

$ mkdir hardhat
$ cd hardhat
$ npm init -y
$ npm install --save-dev hardhat

ついでにOpenZeppelinもインストールしておきます。

$ npm i @openzeppelin/contracts

プロジェクト作成

npx hardhatを実行するとプロジェクトを作成できます。

$ npx hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.9.9 👷‍

作成時にプロジェクトの目的などを聞かれますので選択してEnterで進んでいきます。今回は全てデフォルトのままです。

? What do you want to do? … 
❯ Create a basic sample project
  Create an advanced sample project
  Create an advanced sample project that uses TypeScript
  Create an empty hardhat.config.js
  Quit

? Hardhat project root: › /path/to/project/hardhat
? Do you want to add a .gitignore? (Y/n) › y
? Do you want to install this sample project's dependencies with npm (@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? (Y/n) › y

これでプロジェクトに必要なファイルが自動的に生成されます。

コントラクトの実装

コントラクトの実装はcontracts/以下に行います。
今回は前回実装したコードを少し修正し、mint時に既存のトークン数や1回にmintできるトークン数をチェックするロジックを入れてみました。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract KrocksNFT is ERC721, ERC721Enumerable, Ownable {
    // 定数
    uint256 public constant MAX_SUPPLY = 10;
    uint256 public constant MAX_MINT_PER_TRANSACTION = 5;

    // コンストラクタ
    constructor() ERC721("Krocks NFT", "KRONFT") {}

    // mint時のロジック
    function mint(uint256 numberOfTokens) public payable {
        uint256 ts = totalSupply();
        require(
            numberOfTokens <= MAX_MINT_PER_TRANSACTION,
            "Exceeded max token per transaction"
        );
        require(
            ts + numberOfTokens <= MAX_SUPPLY,
            "Exceed max tokens"
        );

        for (uint256 i = 0; i < numberOfTokens; i++) {
            _safeMint(msg.sender, ts + i);
        }
    }

    // BeforeTransfer
    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override(ERC721, ERC721Enumerable) {
        super._beforeTokenTransfer(from, to, tokenId);
    }
    
    // SupportInterface
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC721, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

テストコードの実装

テストの実装はtests/以下にJavaScriptで実装します。
先程実装したバリデーションに関してテストしてみます。

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("KrocksNFT", function () {
  let KrocksNFT, krocksNFTcontruct, addr1
  beforeEach(async function () {
    ;[owner, addr1] = await ethers.getSigners()
    // デプロイ
    KrocksNFT = await ethers.getContractFactory("KrocksNFT");
    krocksNFTcontruct = await KrocksNFT.deploy();
    await krocksNFTcontruct.deployed();
  })

  describe("mint", function () {
    it('Should be reverted if exceeded max token purchase', async function () {
      await expect(
        // 1回で6個のトークンをmint
        krocksNFTcontruct.connect(addr1).mint(6),
      ).to.be.revertedWith('Exceeded max token per transaction')
    })

    it('Should be reverted because the caller exceeds max token', async function () {
      //10個のトークンをmint
      for (let i = 0; i < 2; i++) {
        await krocksNFTcontruct.connect(addr1).mint(5)
      }
      // 11個目のトークンをmint
      await expect(
        krocksNFTcontruct.connect(addr1).mint(1),
      ).to.be.revertedWith('Exceed max total tokens')
    })
  })
})

テストを実行

$ npx hardhat test

  KrocksNFT
    mint
      ✔ Should be reverted if exceeded max token purchase
      ✔ Should be reverted because the caller exceeds max token (71ms)


  2 passing (619ms)

というわけで無事にテストが通りました。 コントラクトはデプロイ後の修正が出来ないのでしっかりテストしておきたいですね。

おわりに

以上、今回はHardhatを使ったコントラクトのテストについて書いてみました。
今回はローカルネットワークへのデプロイまでは行っていないので今後はその辺りについて書ければいいなと思っています。

参考

以下のサイトを参考にさせていただきました、ありがとうございます!

Hardhat | Ethereum development environment for professionals by Nomic Foundation
公式です。

GitHub - a3994288/erc-721-hardhat-test
やりたいことがドンピシャで実装されており、かなり簡易化して参考にさせてもらいました。
ちゃんと理解できるようにこれからも参考にさせていただきます。

Hardhatで始めるスマートコントラクト開発 | DevelopersIO
ちょうど書こうと思っていた内容が1週間前に公開されていました。いつもお世話になっております。

URPで丸影を真下に落とす方法の検討

はじめに

Shader好きなエンジニアのおうどん(@oudon)です。

キャラクターの影を軽量化するため丸影にしつつ真下に落とす、かつ数も多く表示したい
という要件でShadowMap拡張とDecalの2パターンほど試してみました。

ShadowMapをそのまま利用できないか

軽量にということで、標準のShadowMapを利用してなんとかできないかというところからスタートしました。 特定のオブジェクトだけ常に真上から光を受け取れる状態がつくれば真下に落ちそうです。

丸影

シンプルにキャラクターのCast Shadowsをoffにして、 子オブジェクトに球体を配置しCast ShadowsをShadows Onlyとする方法にしました。
ついでに常に上から光を受けた2Dの円表現になる想定なので一旦Yは0にしてつぶしておきます。

ShadowCaster

基本的にはShadowCasterで影を落とすオブジェクトの位置情報を決定してあげると RenderingPipelineの方でその情報を元に実際にShadowMapに書き込んでくれてるようです。
ということでCaster側でオブジェクトの影が常に真下に落ちるように位置を偽装してやればいけそうな気がします。

URPの標準パッケージ内のコードを参考に改造していきます。

  • Universal RP/Shaders/Lit.shader
  • Universal RP/Shaders/ShadowCasterPass.hlsl

ワールド座標からクリップ座標を計算している該当の処理がGetShadowPositionHClip関数にありました。

float4 GetShadowPositionHClip(Attributes input)
{
    float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(input.normalOS);

#if _CASTING_PUNCTUAL_LIGHT_SHADOW
    float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirectionWS = _LightDirection;
#endif

    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif

    return positionCS;
}

ここのpositionWS(ワールド座標)を弄ってxzのライトによる影響を無効化、yの高さでの変動を無効化してやりました。

    positionWS.xz = positionWS.xz +  lightDirectionWS.xz;
    positionWS.y = lightDirectionWS.y;
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

一旦これでメインライトの向きを無視してオブジェクトの真下に影を落とせました。

が地面の高さが0以外だと影が動いてしまいます。

ShadowReceiver

ということで今度は地面側を工夫して高さの影響を受けない(常にワールド座標のYが0地点の影情報を参照する)ようにしてみます。 地面はShaderGraphでカスタマイズします。 ただしShadowMapの情報を直接取得するノードがないのでカスタム関数を作成して受け取る必要があります。

カスタムノード作成

参考: blog.unity.com こちらの「void MainLight_half」ですね。

また影を自前で受け取るためGraph Settingsの「Receive Shadows」を無効にします。

しかし無効にすると影が表示されなくなってしまいました。
コードを追ってみると

  • GetMainLight (Universal RP/ShaderLibrary/RealtimeLights.hlsl内)
Light GetMainLight(float4 shadowCoord)
{
    Light light = GetMainLight();
    light.shadowAttenuation = MainLightRealtimeShadow(shadowCoord);
    return light;
}

内の

  • MainLightRealtimeShadow (Universal RP/ShaderLibrary/Shadows.hlsl内)
half MainLightRealtimeShadow(float4 shadowCoord)
{
#if !defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    return half(1.0);
#elif defined(_MAIN_LIGHT_SHADOWS_SCREEN) && !defined(_SURFACE_TYPE_TRANSPARENT)
    return SampleScreenSpaceShadowmap(shadowCoord);
#else
    ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
    half4 shadowParams = GetMainLightShadowParams();
    return SampleShadowmap(TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowCoord, shadowSamplingData, shadowParams, false);
#endif
}

どうやらここで戻り値が常に1(影を受けない)でshadowAttenuationにセットされてしまうようです。
MAIN_LIGHT_CALCULATE_SHADOWSの挙動を確認すると 「Receive Shadows」を無効にした場合、未定義になるようです。
とりあえず該当の行を削除して強制的に計算してくれるよう変更します。 ざっとまとめるとこんな感じです。

void MainLight_half(float3 WorldPos, out half3 Direction, out half3 Color, out half DistanceAtten, out half ShadowAtten)
{
#if SHADERGRAPH_PREVIEW
    Direction = half3(0.5, 0.5, 0);
    Color = 1;
    DistanceAtten = 1;
    ShadowAtten = 1;
#else
#if SHADOWS_SCREEN
    half4 clipPos = TransformWorldToHClip(WorldPos);
    half4 shadowCoord = ComputeScreenPos(clipPos);
#else
    half4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
#endif
    Light mainLight = GetMainLight();
    ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
    half4 shadowParams = GetMainLightShadowParams();
    mainLight.shadowAttenuation = SampleShadowmap(TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowCoord, shadowSamplingData, shadowParams, false);
    Direction = mainLight.direction;
    Color = mainLight.color;
    DistanceAtten = mainLight.distanceAttenuation;
    ShadowAtten = mainLight.shadowAttenuation;
#endif
} 

後は参考の通りShaderGraphを組みますが カスタム関数に渡すワールド座標のYに0固定にします。

高さに応じて影のサイズを変更

合わせてShadow Caster側でサイズ調整もやってみました。 先ほどのpositionCSの箇所を

    float3 positionCenter = TransformObjectToWorld(float3(0, 0, 0));
    
    float max = 3;
    float size = saturate(Remap(clamp(positionCenter.y, 0, max), float2(0, max), float2(1, 0)));
    if(size < 0.00001) return 0;
    float3 resizePosOS = input.positionOS.xyz;
    resizePosOS.xz *= size;
    float3 resizePosWS = TransformObjectToWorld( resizePosOS);

    lightDirectionWS *= 0.1;
    resizePosWS.xz = resizePosWS.xz + lightDirectionWS.xz;
    resizePosWS.y = lightDirectionWS.y;

    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(resizePosWS, normalWS, lightDirectionWS));

のような感じで、0位置からの相対でサイズを計算してます。Remap便利

結果

と、ここまでやってそれなりに軽くてそれっぽく表現はできたのですが

  • 完全にこの丸影専用の床です。他の通常の影も拾えますが、Yが0地点の影しか拾えなくなってます。場合によっては使えるかもしれませんが、やはりちゃんと自前で丸影専用のマップ作って合成した方が良さそうです。
  • サイズ調整も地面の高さが0ベースなのでこれもなんとかしないとダメそうです。
  • また床が2重の場合両方に同じ影が反映されます。
補足
  • ここまでとりあえずDirectional Lightのみ想定の検証でした。
  • 他にもDirectional Lightを2つ用意して片方を丸影用に~と考えたんですが、ShadowMapは複数Directional Lightサポートしないので実現できず。
  • また機会があれば、Pipeline側での拡張やCustomFeature等も模索してみたいと思います。

Decal

URPのサンプルにそのままあったので試してみました。

こちらのBlob shadowsです。
Package Manager → Universal RP → Samples → importからダウンロード可能です。 docs.unity3d.com

使用手順

  • Renderer Featureの設定

「Universal Render Pipeline Asset_renderer(Universal Renderer Data)」のAddRenderer FeatureでDecalを追加します。

  • Decal Projectorをセット

影を落とすオブジェクトのCast Shadowsをoffにして 子オブジェクトに「URP Decal Projector」コンポーネントを追加します。 Decalに対応したShaderのMaterialをセットします。URPサンプルの「Decal/BlobShadow/BlobShadow_Mat」参考

以上のようにとても簡単です。

以下のパラメータで投影する範囲を決定するバウンディングボックスを調整できます。

  • Width/Height:投影するサイズ
  • Projection Depth:深さ。投影する範囲の長さ
  • Pivotでバウンディングボックス:positionをoffsetで調整します。

ShaderGraphでDecal対応のShaderを作ることで簡単に差し替えることができます。
なお補足ですが、下に投影する場合transformでX軸のRotationを90にしないと正しく投影されませんでした。

結果

  • 扱いが容易
  • ただしProjectorのバウンディングボックスのサイズとシーンオブジェクトの配置をうまく調整しないとShadowMapの時と同様、床が2重の場合両方に同じ影が反映されたりします。
  • 別物なので標準のShadowMapの影と馴染まない。
  • また複数Decalを使用した際のDraw Distance(描画距離)が個別に判定されない不安定な印象でした。

負荷比較

手元のPCで100オブジェクト分をStatisticsで単純に比較したのですが

結果、影無しの状態を基準に

  • 標準のshadow caster ≠ カスタマイズしたshadow caster が -15fps程度
  • Decalが -20fps程度

と思ったよりそこまで大きな差はなさそうで、Decalが汎用的に活躍できそうな印象でした。
(もちろん飲めるメリット・デメリット次第ではありますが)
またどちらのパターンもチューニングしていない状態なので改善の余地はありそうです。

最後に

ShadowMapの利用に関しては結果というよりか、弄る際の参考になれば幸いです。
Decalに関してはURPの新しめの機能ということもあり、今後も色々試してみたいです。
表題に関しても他のアプローチがあればまた試してみたいと思います。

NetworkedProperty(に相当する機能)のすゝめ

エンジニアの岡村です。

Unityでネットワークマルチプレイを行うアプリケーションを開発する場合、ある程度以上の規模があるのならば、その実装にはネットワークライブラリを利用するのが一般的かと思います。

その際に使われるライブラリにもいろいろな製品があるのですが、メジャーなところではPhotonやMonobitやMirror、最近ならUnity謹製のNetCode for GameObjectも選択肢に上がるでしょう。

弊社では長らくPUN Classic(PUN2ではない)を自社向けにカスタマイズしたものを使ってマルチプレイを実装していました。基本的な機能はPUNに準拠していたのですが、細かい同期の制御をコンポーネント内でのイベントとRPCの実装で行っており、大人数対応などの拡張が難しい状態になっていました。

例えばNEUTRANSでは、プレイヤーの入室時に他プレイヤーからの同期を全てRPCで実装し、プレイヤーがルーム内で描いた絵(UGC)に対して、自前で後から参加したユーザー向けの遅延同期処理もRPCで実装するなど、かなり無理のある使い方をしていました。

そのままでは入室人数を増やしたり、より複雑な機能の開発を行うには限界があったため、思い切って新しい仕組みに書き換えることを決め、新しい選択肢を求めて他のライブラリの検証をいました。その過程で触ったNetCode for GameObjectやPhoton Fuisonといった新しめのライブラリには「NetworkedProperty」というような名前で、同期する必要がある変数1個1個の単位で同期する為の機能が搭載されていることに気づきました。

(UNetにも同様の機能は存在しますが、自分がUNetを触るより前にPhotonを触っていたので知りませんでした)

UnityのマルチプレイライブラリにはRPCと、それ以外の値ベースの同期機能が搭載されていることになります。今まではRPCばかり使っていたので、今回改めてこれらの機能の存在理由について調べ、纏めてみました。

以下の内容では機能名は基本的にFusionのものを利用していますが、他のネットワークライブラリにもほぼ同様の機能が別名で存在するので、適宜読み替えてください。


Unityにおけるマルチプレイライブラリの特徴

前提として、UnityゲームエンジンはGameObjectを処理の単位としています。Unityと深く統合されたマルチプレイライブラリはその設計を汲んで、GameObjectとそこにアタッチしたコンポーネント内で簡単に同期のための機能を利用できる仕組みを搭載しています。

マルチプレイを制御するRunnerが一つと、それの子としてマルチプレイのシーンを構成するObjectが0個以上ある形になります。また、Runner内におけるインターネットの向こうにあるサーバーやクライアントとメッセージをやり取りする仕組みと、Runnerが各Objectに対して同期サービスを提供する仕組みは分離可能な実装になっていることが多いです。

NetworkObjectは基本的にGameObjectにコンポーネントを付けたPrefabの形で実装されます。同期するにはPrefab同士が同じ構造である必要があるので、それぞれのPrefabにはIDが振られており、特定のIDで特定のPrefabが取得できるようになっています。必要になったタイミングでInstantiateするとともにネットワーク上で一意となるIDを振り、Runnerと接続して初期化を行います。

NetworkedProperty

変数の同期機能を持っているライブラリは、コンポーネントに同期ロジックを実装する際、フィールドメンバーをAttribute等で同期可能な変数としてマークします。マークされた変数は同期サービス側で監視され、値の変更があれば自動でシリアライズされて他のクライアントに同期されます。

基本的にこの同期はいずれかのクライアント上の値を真として一方向のみで行われ、他のクライアント上で値を変更しても反映されません。これはどちらのクライアントが持つ値が正しいのかをハッキリさせ、お互いが値を送り合って状態が収束しなくなってしまうのを防ぐためです。

RPC(Remote Procedure Call)

RPC(Remote Procedure Call)は、同期オブジェクト側から能動的にリクエストが送られる機能です。RPCとしてマークされたメソッドを呼び出すと、その引数がシリアライズされて受け取り手に届きます。これらの処理は基本的に即時に、遅滞なく行われます。

ただし、Networked Propertyのように状態を持っておらず、送信しようとしたタイミングで送信相手がルーム内に存在する必要があります。後から入室した相手には届きません。

ちなみに、PUN2ではRPCをバッファリングして後から入室したプレイヤーにも送信する機能がありましたが、Fusionでは削除されました。この変更は恐らくRPCをステートレスにすることで、NetworkedPropertyとの使い分けを明確にしたのだと思います。

使い分け

RPCは、

  • 特にリアルタイム性が要求される同期処理(マルチプレイゲームでの攻撃処理)
  • 同期権限のないクライアントから、同期権限のあるクライアント(サーバー)へ値の更新を依頼するとき
  • 状態の変更を伴わない一時的なトリガーの同期(送信経路を選べるなら、到達保証を無しにしても良い)

一方で、NetworkedPropertyは、

  • RPCで挙げた以外の同期処理全て

これくらいの認識で使い分けをして問題ないと思います。

Fusionでも入退室時のイベントと、RPCを使うことで全てRPCで完結することもできなくはないですが、それではここでは紹介しなかったFusionの様々な便利機能を使えない、実装の手間が増える、同期時の負荷が上がるといったデメリットが色々あるので、基本的にはNetworkedPropertyとRPCを組み合わせて同期処理を作っていくのがいいでしょう。

上図のフローを参考に同期するコードを書いてみるとこのような感じになります。

using Fusion;
using UnityEngine;

public class SampleScript : NetworkBehaviour
{
    [Networked(OnChanged = nameof(OnColorChanged))]
    private Color Color { get; set; }

    public void ChangeColor(Color color)
    {
        RPC_SetColor(color);
    }

    [Rpc(sources: RpcSources.All, RpcTargets.StateAuthority, InvokeLocal = true)]
    private void RPC_SetColor(Color color)
    {
        Color = color;
    }

    private void OnColorChanged()
    {
        var block = new MaterialPropertyBlock();
        block.SetColor("_Color", Color);
        var renderer = GetComponent<Renderer>();
        renderer.SetPropertyBlock(block);
    }
}

他のプレイヤーに対する同期は全てNetworkedPropertyがやってくれるので、コードではとにかく「同期権限のあるクライアント側のNetworkedPropertyに値を入れる」「NetworkedPropertyが更新された時にビューを更新する」の2点だけを考えればよくなっています。

ちなみに、以前はこんな感じで実装していました(PUN2のコード)。

using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class SampleScript : MonoBehaviourPunCallbacks
{
    private Color color;

    public void ChangeColor(Color color)
    {
        photonView.RPC(nameof(RPC_SetColor), RpcTarget.All, color);
    }

    [PunRPC]
    private void RPC_SetColor(Color color)
    {
        this.color = color;
        var block = new MaterialPropertyBlock();
        block.SetColor("_Color", color);
        var renderer = GetComponent<Renderer>();
        renderer.SetPropertyBlock(block);
    }

    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        if (photonView.IsMine)
        {
            photonView.RPC(nameof(RPC_SetColor), newPlayer, color);
        }
    }
}

コード量はあまり変わりませんが、他のプレイヤーが入室したときの処理を手動でハンドリングしています。このコードではオブジェクトが大量にあった場合、入室時に現在の状態を同期する為にRPC通信が大量に飛ぶことが予想されます。小規模なアプリであれば動作に問題はないのですが、大規模な拡張をするのは難しいでしょう。

その他

NetworkedPropertyでも対応が難しいパターン

先程はNetworkedPropertyを万能かのように書いたのですが、そもそもリアルタイムネットワークの特徴として、あまり大容量のデータを扱うことは苦手です。そのため、UGC(ユーザーが作ったコンテンツ)の同期、特にマルチプレイ空間内で動的に作成される作品を同期したりといった事は苦手です。そのようなものを実装することになった場合は、大人しくUGCの同期用の仕組みを別途用意するのがいいでしょう。

NetworkedPropertyの拡張

この仕組みはルーム内の全ての状態が同期サービス側から読み書きが可能な為、その口をほんの少し拡張することで、ルーム内の状態のセーブ、ロードやルーム状態のCDNを通じたライブ配信など、様々な応用が出来るのではないか、と考えています。

おわりに

最近の同期ライブラリの情報自体は追っていたのですが、実際に使っているコードはPUN Classicをカスタマイズしたものだった為、この記事を書くにあたって調査するたびに新しい発見があって大分情報が古くなっているな、と改めて感じました。やはり手を動かしてみるのが大事ですね。

今回の記事はPhoton Fusionに限らないモダンなUnityのマルチプレイライブラリに共通した考え方について書いた(つもり)です。Photon Fusionにしかないような便利な機能は沢山あるのですが、この場での紹介は割愛しました。もしPhoton Fusionに興味がある方は、先日行われたこちらのセミナーの資料が参考になったので、是非一読をお勧めします。

photonjp.connpass.com

Unityで画像データをいい感じにロードしたい

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

今回はUnityでの画像データのロード方法に関する記事になります。

たかが画像データですが、意外とまだ王道の取り回しが確立されていない印象です。

最近また画像のロード周りを触る機会があり、いろいろな方法を調べてみたのでその内容をまとめてみました。

背景

画像データのロード方法の悩み

画像データをただUnity内に取り込んで利用したいだけの場合は、JPGやPNGのファイルをUnityEditorで取り込むとTextureやSpriteに変換してくれますので、それほど困ることはないと思います。

しかしUnityのプロジェクトの外部から画像データをロードして表示したいという場合には、意外とひと手間二手間かかります。

今回私が調査した画像のロード方法で重要視した観点は以下になります。

  1. なるべくメインスレッドを止めないで、Unityの外にある画像データをUnity内で表示したい
  2. JPG、PNGを含む標準的な画像コーデックは対応したい
  3. Windows/macOS/Android/iOSなどのマルチプラットフォームで動作させたい

画像データはデータサイズが4Kや8Kまでいかなくてもデータの処理自体にそれなりに時間がかかるため、UnityのUpdateの数~数十フレーム分の負荷がかかってしまい、画面の更新が少し止まってしまうことも珍しくありません。

使用する画像が事前に分かっている場合には解像度を落とすなど最適化をする余地もありますが、外部から様々な画像データを読み込むようなユースケースでは制御が難しいです。

例えばVRのアプリケーションでは画面のフリーズはVR酔いにつながるため、致命的な体験の質の低下を起こしてしまうこともあります。

また、画像データのコーデックはいくつもあるため、それらの対応可能な範囲も気になるところです。

マルチプラットフォーム開発ができるというのがUnityの強みの一つであるので、どのプラットフォームで動作するのかも確認していきたいと思います。

Unityでの画像のロード方法の基本的な流れ

画像をロードしてUnityで表示させる処理の基本的な流れは以下のようになるかと思います。

JPGやPNGの画像データのbyte[](or Stream)を取得する

→ デコードしてピクセルデータのbyte[](+WidthやHeightなどのメタデータ)に変換する

→ UnityのTexture(2D)にピクセルデータを読み込ませる

最初の画像データの取得方法はローカルデータならSystem.IOでできますし、URLから取得するならHTTPClinetでも構いません。

また、UnityのTextureなどのUnityが管理しているオブジェクトにアクセスして読み書きするAPIは基本的にはメインスレッドでしか行うことができません。

ですので要件1のメインスレッドをなるべく止めないことを実現するためには、画像データのデコード処理をメインスレッド以外の別スレッドで実行できることはポイントの一つになります。

紹介

UnityEngine.ImageConversion.LoadImage

Unityの公式のAPIに昔からあるLoadImageです。

docs.unity3d.com

対応しているコーデックはJPG、PNGだけというのはありますが、このロード方法では画像のバイナリーデータのデコードも含めてすべてメインスレッド上で行われるため、メインスレッドを占有してしまう時間が長くなってしまいます。

UnityWebRequest(と昔のWWWクラス)からTextureを取得する方法も(おそらく)内部では同じ挙動をするため、同じ問題を持っています。

docs.unity3d.com

メリット

  • Unityに組み込まれているため特別な準備をすることなく使える

デメリット

  • メインスレッドを止めてしまう
  • JPG、PNGしか対応していない

AssetBundle/Addressables

Unity公式の機能のAssetBundleやAddressablesを使ってもTextureして外部からのロードをすることもできます。

docs.unity3d.com

docs.unity3d.com

ただし事前にAssetBundleデータのビルドをする必要があったり、プラットフォーム別にそれぞれビルドを用意する必要があります。

使用する画像データがある程度決まっていてコントロールできる場合には十分有用ですが、アプリのユーザーが画像をアップロードして使用する場合などデータが事前に用意できないケースには向きません。

メリット

  • 圧縮されている画像を扱える
  • 他でAssetBundle/Addressablesを使用している場合には乗っかれる

デメリット

  • AssetBundleビルドがプラットフォーム毎に必要
  • 事前に画像データの用意が必要

Unity.IO.LowLevel.Unsafe.AsyncReadManager.Read

比較的最近追加されたUnity公式のAPIに、AsyncReadManagerというものがあります。

ざっくり説明するとファイルのロードを非同期に、かつUnsafe(つまりUnmanaged Memory上で)でロードできるものです。

既にいくつか紹介記事もありますので詳しくはこちらなどをご覧ください。

zenn.dev

qiita.com

これも十分有効に使える場面もあるとは思いますが、後者の記事にあるようにUnity上での画像の圧縮形式を考慮する必要があるため、前者の記事のように一度ローカルにTextureデータをロードしておいたり、プラットフォームによって異なる圧縮形式の対応を考えたりする必要があります。

一度ローカルに保存するためにUnityWebRequestなどでTextureに変換してしまうとそのプロセスでメインスレッドを占有するため、リアルタイムにロードするのにはあまり向かないかもしれません。

メリット

  • Unsafeで扱えるのでメモリの負担が少ない
  • 非同期APIが用意されている

デメリット

  • 圧縮形式を考慮する必要がある

System.Drawing

C#の標準の機能を調べて見ると、System.Drawingというクラスで画像をBitMapにデコードできることが分かります。

docs.microsoft.com

ただUnityにはこのSystem.DrawingのDLLが含まれていないため、さっと使用するのは難しそうです。

qiita.com

メリット

  • C#の標準機能

デメリット

  • Unityで使用するのは大変

FreeImage

Unity公式のAPIでも、C#の標準APIでも適切なものが見つからない場合には、オープンソースのライブラリを探してみます。

比較的有名な画像処理のライブラリに、FreeImageというものがあります。

github.com

弊社のNEUTRANSというプロダクトでも採用しているライブラリです。

このライブラリの注意点は、動作する環境がStandalone(Windows/macOS/Linux)のみで、Android/iOSでは動かないという点です。

C/C++で書かれているため原理的には適切にビルドをすれば動きそうな気もしますが、弊社の別のメンバーが試したところうまくいかなかったとのことです。

メリット

  • Unsafeで扱えるのでメモリの負荷が少ない
  • 別スレッドで実行可能
  • OSS

デメリット

  • Standalone(Windows/macOS/Linux)のプラットフォームでしか動作しない
  • 自分で導入する必要がある

UnityAsynImageLoader

「Unity Image Loading」などで検索していたところ、こんなライブラリを見つけました。

github.com

READMEに書かれていることはまさに同じ課題意識のため「これは!」と思ったのですが、内部ではFreeImageを使用しているようなので、スマホで動かすのは難しそうです。

APIは綺麗に作られているので、FreeImageのWrapperとしては使いやすいのではないでしょうか。

メリット

  • FreeImageを触りやすくしてくれている
  • OSS

デメリット

  • FreeImageのデメリットを引き継いでいる

OpenCV for Unity

Twitterでいいライブラリはないものかとつぶやいていたところ、フォロワーさんからOpenCV for Unityというアセットを教えていただきました。

assetstore.unity.com

これは画像処理のOSSで有名なOpenCVをUnity向けに組み込み、拡張したアセットになります。

opencv.org

なんとWindows/macOS/Android/iOSに加えて、WebGLやUWPなどのほとんどのメジャーなプラットフォームにも対応してます。

早速会社で購入してもらい、実際に触ってみました。

結論から言うと当初の要件を満たすことはできますが、少し問題点もありました。

  1. OpenCVのピクセルデータクラスのMatへの変換処理は別スレッドで実行できるので、メインスレッドの負荷を減らせる
  2. 各プラットフォームの動作確認もできたが、iOS向けのビルドのPostProcess処理(Xcodeでのライブラリ参照など)に癖があり少しカスタマイズが必要だった
  3. Native Pluginのファイルが単体で100MBを超えるものが複数あり、GitHubだとGit LFSを使用しないといけない、かつかなりの容量を使用する。
  4. OpenCV本家のThirdPartyLicenseが多くてライセンスのチェックが大変そう(全部確認したわけではありません)
  5. ただFFMPEGは手動で入れない限り入らないのでそこの不安はなさそう
  6. いろいろな画像処理をできる反面、画像をデコードしたいだけだとややオーバースペック

すごく便利なアセットであることには間違いないので一度導入はしてみたのですが、課題も見えてきたため最終的には別のライブラリに置き換えることになりました。

メリット

  • NativePluginで主要なプラットフォームにはほとんど対応している
  • 画像のデコード以外にもOpenCVの様々な機能が使える
  • Unity向けの拡張やデモが用意されていて比較的触りやすい

デメリット

  • NativePluginのファイルサイズが100MBを超えるものが複数ある
  • iOS向けのビルドは少しケアが必要
  • 画像をロードするだけのために入れるには機能が豊富過ぎる
  • 有料

UnityのAssetStoreで検索

OpenCV for Unity以外ではあまりいいアセットが見つかりませんでした。

Native Pluginを自作する

適切なものがない場合や、パフォーマンスを重視するようなケースでは、Native Pluginを自作するのも一つの手です。

実際に凹さんが記事にしているものがあります。

tips.hecomi.com

tips.hecomi.com

tips.hecomi.com

Native Pluginは対応したいプラットフォーム向けにそれぞれ作る必要があること、そのメンテナンスも必要なことをクリアできる知識とリソースがあれば自由度が高くパフォーマンスも高い手段になるでしょう。

今回は対応プラットフォームが多く、かつ画像のロード機能はそこまでコアな機能でもないためそこまでリソースは割けませんでした。

メリット

  • パフォーマンスが良い
  • 自由にカスタマイズが可能

デメリット

  • 複数のプラットフォーム別に用意する・運用するのが大変

結局どの方法を採用したのか?

OpenCV for Unityを導入して悩んでいたのですが、まったく別のところで3Dモデルをロードする機能を作った際に利用したTriLibというアセット

assetstore.unity.com

のライセンスのチェックをしていたところ、TriLibの内部で画像のデコードに利用しているStbImageSharpというライブラリがあることを知りました。

StbImageSharp

github.com

このライブラリの特徴的なところは以下になります。

  1. JPG、PNG含むメジャーなコーデックをサポートしている(PSDとGIFに対応しているのは謎にすごい)
  2. NativePluginを使用せずPure C#で書かれている、つまりUnityのどのプラットフォームでも動作する(パフォーマンスは少し落ちる)
  3. ただしUnity向けには作られていないので、少し拡張が必要

最後のUnity向けの拡張さえあれば当初の要件が満たせそうなため、自分で作ることにしました。

StbImageSharpForUnity

github.com

画像のデコード処理を別スレッドで処理するデモも用意しました。

まだメモリの取り扱いを最適化しきれていないですが、パフォーマンスをそこまで気にしなくてもいいのであればプラットフォームを気にせずに取りまわせる使いやすいライブラリなのではないかなと思います。

というわけで結局StbImageSharpを利用した方法を採用することになりました。

メリット

  • デコード処理を別スレッドで実行できる
  • 対応コーデックが多い
  • マルチプラットフォームの対応が容易

デメリット

  • パフォーマンスはそこまで良くない

まとめ

画像データのロード方法を様々紹介しましたが、どれが完璧というわけでもないですし、プロジェクトの要件によって最適な方法は変わるかなと思います。

今回はあまり紹介できませんでしたが、メモリの最適化、非同期処理、残るTextureのAPIアクセスのオーバーヘッド、大きい画像の分割ロード、GIF画像の取り扱いなどまだまだ突き詰められる余地があります。

たかが画像データと思いきや意外と奥が深い世界です。

また、今回紹介したのは私が知っている or 検索できた範囲になっていますので、「他にもこんな方法、アセット、OSSもあるよ!」というのもありましたらTwitterなどで教えていただけると嬉しいです!

不正確な記述もありましたらご指摘いただけますと幸いです。

最後に紹介したこちらも使ってみてのフィードバックも大歓迎です。

github.com

以上、Unityで画像データをいい感じにロードしたいけど何か困っているという方の参考になれば幸いです。

NestJS+Svelteを使って簡単なSSRを試してみた

はじめに

エンジニアの松原です。普段の業務ではバックエンド開発業務が多く、たまにはフロントエンドのこともしたいと思いつつ、新しいWebフレームワークにも触れたいと考えたため、 バックエンドとしてRails風にMVCベースの設計を持っているNestJS(厳密にいうと、NestJSはViewはデフォルトでは持っていませんが)、描画エンジンとしてフロントエンドのWebフレームワークであるSvelteを組み合わせて Hello World! をやってみました。

尚、今回はとっかかりとしてのコードしか書いておらず、ちゃんとSSRを行うにはSvelteをTypeScript対応にしたり、Webpackで依存するパッケージをバンドルするなどの手続きが必要になります。 以降フォローできればと思います。

また、今回はPCに node (v16以降、と npm ) が入っていることを前提に記事を書いています。

NestJSのセットアップ

NestJSのCLIが必要なので、npmからインストールしておきます

npm install -g nest

NestJSのプロジェクト作成を作成する

CLIから新規のプロジェクトを作成します

nest new example-my-project

その際、パッケージマネージャーを何にするか聞いてくることがありますので、選択します(矢印キーで操作して選びます) 今回はyarnを使用したいので、yarnを選んでいます

⚡  We will scaffold your app in a few seconds..

CREATE example-my-project/.eslintrc.js (665 bytes)
CREATE example-my-project/.prettierrc (51 bytes)
CREATE example-my-project/nest-cli.json (118 bytes)
CREATE example-my-project/package.json (2002 bytes)
CREATE example-my-project/README.md (3340 bytes)
CREATE example-my-project/tsconfig.build.json (97 bytes)
CREATE example-my-project/tsconfig.json (546 bytes)
CREATE example-my-project/src/app.controller.spec.ts (617 bytes)
CREATE example-my-project/src/app.controller.ts (274 bytes)
CREATE example-my-project/src/app.module.ts (249 bytes)
CREATE example-my-project/src/app.service.ts (142 bytes)
CREATE example-my-project/src/main.ts (208 bytes)
CREATE example-my-project/test/app.e2e-spec.ts (630 bytes)
CREATE example-my-project/test/jest-e2e.json (183 bytes)

? Which package manager would you ❤️  to use? (Use arrow keys)
> 

プロジェクトディレクトリに移動します。

cd example-my-project

READMEが自動生成されていますので、package.jsonと合わせて、自分の情報に書き換えます。

Svelteのパッケージをインストールする

サーバーサイドでhtmlレンダリングを行うためにSvelteの設定を追加していきます。 まずは必要パッケージをインストールします。それぞれ svelte はSvelte本体、 svelte-check はSvelteのテンプレートの記述を診断するCLIツールになります。

yarn install -D svelte svelte-check

SvelteをViewEngineとして設定する

NestJsのControllerからhtmlのレンダリング処理を自分でコントロールしたい場合、描画処理の手続きを記述する必要があります。 今回はSvelteのテンプレートを使うため、Svelteのモジュールに処理を実行させるコードを書きます。 src/ 配下に svelte-view-engine.ts というファイル名を追加し、以下のコードを追加します。

import 'svelte/register';

interface NodeCallback<T> {
  (err: any, result?: undefined | null): void;
  (err: undefined | null, result: T): void;
}

export function renderWithViewEngine(filePath: string, options: any, next: NodeCallback<any>) {
  const component = require(filePath).default;
  const { html } = component.render(options);
  next(null, html);
}

src/main.ts にこのコードを反映させます。

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { resolve } from 'path';
import { AppModule } from './app.module';
import { renderWithViewEngine } from './svelte-view-engine';

async function bootstrap() {
  // const app = await NestFactory.create(AppModule); // <- 変更前の記述
  // 以降を追加
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.engine('svelte', renderWithViewEngine);
  app.setViewEngine('svelte');
  app.setBaseViewsDir(resolve('./src/views'));
  // ここからは以前と同様
  await app.listen(3000);
}
bootstrap();

Svelteのテンプレートをレンダリングできるようにする

Svelteのテンプレートを作成します。 src/ 配下に views というディレクトリを作成し、 Layout.svelte を作成し、以下のコードを追加します。 このファイルはhtmlを作成する際のひな型になります。

<script>
   export let title;
</script>

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{title}</title>
  </head>
  <body>
    <slot />
  </body>
</html>

同じく Home.svelte を作成し、以下のコードを追加します。先ほどの Layout.svelte をベースにして、 <slot /> の箇所に Home.svelte の内容が流し込まれます。

<script>
   import Layout from './Layout.svelte';
   export let message = "";
   export let title = "";
</script>

<Layout title={title}>
    <h1>{message}</h1>
</Layout>

これらのテンプレートを使ってレンダリングするためには、コントローラー側に対象のビュー設定を行うためのメソッドデコレータを指定する必要があります。 src/app.controller.ts を書き換えます。新たに Render のメソッドデコレータを追加し、描画するテンプレート ( この場合は Home -> Home.svelte という対応関係) を指定します。

import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @Render('Home')
  getHello(): object {
    return {
      message: this.appService.getHello(),
      title: 'Home'
    };
  }
}

実行してみる

以下のコマンドを実行し、NestJSを開発モードでローカル実行してみます。

yarn start:dev

以下のようにNestJSのサービスが稼働していれば大丈夫です。

[10:33:31] Starting compilation in watch mode...

[10:33:33] Found 0 errors. Watching for file changes.

[Nest] 28600  - 2022/**/** **:**:**     LOG [NestFactory] Starting Nest application...
[Nest] 28600  - 2022/**/** **:**:**     LOG [InstanceLoader] AppModule dependencies initialized +26ms   
[Nest] 28600  - 2022/**/** **:**:**     LOG [RoutesResolver] AppController {/}: +5ms
[Nest] 28600  - 2022/**/** **:**:**     LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 28600  - 2022/**/** **:**:**     LOG [NestApplication] Nest application successfully started +3ms

http://localhost:3000 にアクセスします。

無事NestJS + Sveleteで Hello World! ができました。

まとめ

今回はじめてNestJSを触りましたが、NestJSコード構造は、Railsの設計にも若干似ており、(RubyとTypeScriptという開発言語の違いはありますが)Rails開発で培ってきた経験がそのまま利用できるイメージがあります。
また、NestJSにはTypeScriptで利用できる強力なデコレータが用意されており、JavaのSpringのアノテーションにも似ており、メンテナンス性を考えて丁寧に設計されていることが良く分かります。
これまでがっつりMVCベースのWebフレームワークを触ってこられた方は使いやすいWebフレームワークなのかなと思います。

公式サイト(英語)のOverviewを見ていくと、どういったWebフレームワークなのか理解を深められると思いますので、NestJSに興味を持っていただいた方は是非お読みください。

docs.nestjs.com

SvelteはReactやVueと似たようなものかと考えていましたが、実際触ってみるとPugのようなテンプレートエンジンに近いイメージを持ちました。独特な構文が少ないため、学習コストもReactやVueと比べると非常に少ないと思います。
今回はアドホックなやり方でSvelteを使っているため、ブラウザ上のロジックが動作しない(動的バインディングや関数が動作しない)のですが、本来のSvelteはSvelteはReactやVueと同様にブラウザ上にロジックを持たせることができるWebフレームワークのようですので、Svelteが気になる方は公式サイトをぜひ除いてみてください。
公式の対話型チュートリアルを通して、実際にコードに触れながら学ぶことができますので、空き時間にポチポチ触ってみると良い時間つぶしになりそうです。

svelte.jp

次回以降の記事では具体的なWebアプリケーションとして動作する仕組みを試していければと考えております。