unitypackage形式のアセットをupmパッケージに改造する

エンジニアの岡村です。

Unity2020.1から実装されたupmはバージョンアップを重ね、GUIでのインストールやカスタムレジストリの登録まで出来るようになり、かなり実用的といえる機能になってきています。

しかし、Unity AssetStoreで配布されているアセットは.unitypackage形式での配布形態しかサポートしておらず、未だに多くのアセットがAssetsフォルダに直接インストールされる仕様になっています。

社内のプロジェクトにおいてはUnity向けに作成した作成したライブラリはそのほとんどがupm化して管理されているので、Assetsに直接インポートしなければならないアセットも同様に管理できないか試してみた、というのが今回の趣旨となります。

当然ながら、Unityもしくはアセット提供者が公式で対応してもらえたら不要となるので、あまり長く使えるテクニックではありませんが、自作ライブラリを後からupm対応する時などに参考にしていただければ幸いです。

⚠️注意!⚠️

Asset Storeで販売されているアセットを含め、誰かが制作したアセットをupm化して第三者がダウンロードできる様な状態にする事は、ライセンスによって認められていない限り著作権の侵害にあたります。必ずアセットの改変が許されているかどうかを確認し、upm化する場合はプライベートリポジトリやLAN内など、第三者がアクセスできない場所に置いてください。

特にAssetStoreのアセットの場合、アドオン>サービス のカテゴリに含まれるアセットは標準Unity Asset Store EULAにて改変自体が禁止されている為注意が必要です。

アセットをupm化するメリット

  • Assetsフォルダ内に第三者の作ったアセットが入らないので、プロジェクトの見通しが良くなります。

  • プロジェクトの領域ではないコードやアセットをReadOnlyにし、リファクタ等による思わぬ変更の発生を防ぐことが出来ます。

  • アセットのバージョン情報がPackages/manifest.json及びPackages/packages-lock.jsonに残るので、現在プロジェクトで利用しているアセットのバージョンを一元管理できます。

  • プロジェクトのgitリポジトリサイズが小さくなります。

upm化に向かないパターン

Assetsフォルダ以下に直接置く場合とパッケージとして参照する場合とでは仕様が異なる部分があるため、upm化が不可能、もしくはメンテコストを考えた場合に釣り合わないアセットもあります。

  • 複雑な処理をするEditor拡張を含んでいる場合、ReadOnlyになること、本来のAssetsフォルダ内ではない場所にアセットが配置されることにより、正しく動作しなくなる可能性があります。

  • アセットがnpm semverに準拠しないバージョンを付けている場合、そのままではパッケージレジストリでは管理出来ないので、独自のバージョンを付けるなどの対応が必要です。

  • 3Dモデルやテンプレートなど、改変を前提としたアセット。

upm化する判断基準

以下に当てはまるパッケージはupm化の適性が高いです。

  • C#やdll等のプラグインがメインのパッケージ
  • Editor拡張がメインではない
  • AssemblyDefinitionや名前空間が定義されている

アセットをupm化するワークフロー

大まかな手順は以下の通りです。

  1. 対象アセットをパッケージとして管理する為の専用プロジェクトを作る

  2. プロジェクト内に対象アセットをインポートする

  3. パッケージ管理したい単位でpackage.jsonを作成する(記法はこちら

  4. 必要に応じてアセットを改変し、改変した個所をREADME.md等にメモしておく

  5. Unityで正常に動作するか確認し、ダメなら4を繰り返す

  6. バージョン名のタグをつけてプロジェクトで利用できるようにする

例えば PUN2 - FREE (2.40)をupm化するとなると、以下のようなプロジェクト構造になると思います。(一例)

Assets
└─Photon
   ├─PhotonChat
   ├─PhotonLibs
   │  └─package.json
   ├─PhotonRealtime
   │  ├─Code
   │  │  └─package.json
   │  └─Demos
   └─PhotonUnityNetworking
      └─package.json

package.jsonを配置した個所がパッケージとして管理するようにした部分です。PUNはChat, Libs, Realtime, PUNの4つのライブラリが一つになったアセットとして配布されて いるので、その中でRealtime, PhotonLib, PUNを個別にパッケージ化しています。

またPUNに関しては、PunSceneSettingsPhotonNetwork.LoadOrCreateSettings() などのプロジェクト固有の設定ファイルを自動生成するスクリプトが含まれている為、それらのコードを改造する必要があります。簡単な対処をするならば、これらのコードにおけるアセットファイルの生成パスをAssets以下の固定パスに強制し、利用先プロジェクト内で生成されたアセットを管理していく形になると思います。下のコードは改変場所と改変例です。

// Assets\Photon\PhotonUnityNetworking\Code\Editor\PunSceneSettings.cs:50
public static string PunSceneSettingsCsPath => "Assets/Photon/" + SceneSettingsFileName

// Assets\Photon\PhotonUnityNetworking\Code\PhotonNetwork.cs:3212-3214
string punResourcesDirectory = "Assets/Photon/Resources/";
string serverSettingsAssetPath = punResourcesDirectory + PhotonNetwork.ServerSettingsFileName + ".asset";
string serverSettingsDirectory = Path.GetDirectoryName(serverSettingsAssetPath);

ただし、これらの改変を施した場合は今後のアップデート時にも同様の修正をする必要があります。

作成したパッケージをプロジェクトから参照する

改変したアセットはGitHub上にプライベートリポジトリとして配置し、URLで直接参照してインポートすることができます。プライベートリポジトリに配置したパッケージを実際に利用するには、以前書いた以下の記事が参考になると思います。

synamon.hatenablog.com

この方式では依存関係の解決など高度なパッケージの機能は使えないのですが、.unitypackageからパッケージ化したアセットに関しては大きな問題にならず、パッケージレジストリを準備する必要もないため、このスタイルを採用しても問題は少なそうです。今後パッケージ数が多くなりメンテナンスが大変になれば社内専用パッケージレジストリを採用するかもしれません。ただ、便利になればなるほどライセンス数の管理などが大変になりそうな気がするので、その前にUnity AssetStoreで配布されているアセットがupmパッケージに対応してくれるといいなと思っています。

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で画像データをいい感じにロードしたいけど何か困っているという方の参考になれば幸いです。