すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【Extenject】Composite Installer を紹介!

f:id:tsgcpp:20210221104046j:plain

概要

今回は Extenject に追加された Compsote Installer について紹介します!

github.com

ちなみにPullRequestを出したのは私です(欲しかったので)。

github.com

まだComposite Installerが同梱されているリリースバージョンはありませんが、 せっかくなので活用方法と、ついでにマージされるまでの小話について記事にしようと思います。

記事の最後にunitypackageとタグへのリンクを記載していますので、使ってみたいと思った方はご利用ください!

今回の記事は Extenject にある程度慣れている方向けの内容です。

Compsote Installer について

Composite Installer とは Compositeパターンを使った Extenject のInstaller です。

Compositeパターン

Extenject観点で説明しますと、 Composite InstallerをDIContainerに登録するだけで、そのComposite Installerに登録されているすべてのInstallerで InstallBindings()が実施されます。

また、Composite Installer に 別のComposite Installer を登録することも可能です。

f:id:tsgcpp:20210221105446j:plain

Compositeパターンはメジャーなデザインパターンのため、解説記事もたくさんあります。
詳しくは検索してみてください。

Compsote Installer の活用術

1. 再利用可能なInstallerグループを作成可能

Compositeパターンの通り複数のInstallerを1つのComposite Installerにグループとして集約することで、再利用性のあるInstallerとしての管理が可能になります。

  • 機能ごとにInstallerのグループとして管理
  • 機能ごとにComposite Installerを作成しシーンごとに取捨選択して利用

f:id:tsgcpp:20210220201233j:plain

Extenjectを利用してかつ Pure C# が増えてくると比例してInstallerクラスも増えてくると思いますが、
そんなときに目的や機能ごとにComposite InstallerにInstallerグループを分割登録して管理すると良いと思います!

2. 疎結合Installer, 抽象Installerとして活用

特定のInstallerの前にComposite Installerをレイヤーとして設けることで、ContextとInstaller間を疎結合化することができます。

  • 特定のInstallerに依存しない形で各Contextに登録可能
  • ContextのPrefabを編集することなく登録Installer群を変更可能

f:id:tsgcpp:20210220203403j:plain

特にComposite Scriptable Object Installerはオススメです!

ContextはアセットをGUID経由で取り込む形になるため、
特定の機能にInstallerを追加したい場合はContextを編集せずComposite Scriptable Object Installerアセットに新しいInstallerを登録するだけで済みます。

f:id:tsgcpp:20210220204459p:plain

3. 特定の機能提供向けInstallerとして配布

複数のチームでExtenjectを利用している場合の連携に活用できると思います。

  • 特定の機能を集約したComposite Installerをパッケージに含めて共有可能
  • Composite Installerへの修正やInstaller追加もパッケージの更新のみで配布先に反映することが可能

Compsote Installer の使い方

以下にドキュメントページがあります。
こちらも私の方で作成して一緒にPullRequestに出しました(ガバガバな英語で恐縮ですが)。

CompositeInstaller.md

せっかくなので、簡単にではありますが日本語でも使い方を紹介します。

CompositeMonoInstaller

名前の通り MonoInstaller の Compositeパターン版です。

コンポーネントはすでにExtenjectの中に含まれていますので、他のMonoBehaviourと同様にAdd Component可能です。

また、Prefab化することで Prefab Installerとしても利用することができます。

f:id:tsgcpp:20210220210533p:plain

f:id:tsgcpp:20210220210650p:plain

CompositeScriptableObjectInstaller

名前の通り ScriptableObjectInstaller の Compositeパターン版です。

Create -> Zenject -> Composite Scriptable Object Installer をクリックすることで作成可能です。

f:id:tsgcpp:20210220211141p:plain

f:id:tsgcpp:20210220211620p:plain

先程も述べましたが、個人的には ScriptableObjectInstaller をよく使うので特にオススメしたいです!

FYI: 循環参照の検知

Compositeパターンは性質上、循環参照ができてしまいます(詳細は後述)。
指定ミスによるヒューマンエラーを回避するために、循環参照を検知したときはInspector上のプロパティが赤くなるEditorも用意しました。

f:id:tsgcpp:20210220212259p:plain

f:id:tsgcpp:20210220212029j:plain

ちなみにこの状態で起動すると例外(ZenjectException)が飛びます。

Composite Installer 開発小話

標準搭載化

ありがたいことにPullRequestを出したところ、CollaboratorのMathijs-Bakkerさんに「標準的な機能になるから標準フォルダに移動してもいいんじゃないか?」とコメントをいただけました。

実質的に標準搭載化となったため追加修正を加えた上でマージされることになりました。
そのおかげで5日ぐらいは修正に費やすことになりましたけど、正直に嬉しかったのでモチベは高かったですw。

「プルリクって普通標準搭載になるもんじゃないの?」って思われた方もいるかもしれないので補足すると、
Extenject (Zenject) には OptionalExtras というオプション的機能を詰め込んだフォルダがあります。
当初はこちらに 拡張機能としてComposite InstallerのPullRequestを出していたのですが、上記のようなご提案をいただけたため標準搭載となりました。

みなさんの開発の手助けになれば嬉しいです。

そういえば、PullRequestですがmergeじゃなくてrebaseで取り込まれたのは若干気になりました。
ソースコード自体はそのまま取り込まれたので問題ないと思いますけど。

循環参照対策

Compositeパターンは循環参照に注意する必要があることを知っていたので、循環参照対策も主に2つ入れています。

  • 循環参照があるComposite Installer があった場合は例外を出す
  • エディタ上で循環参照を検知したらInspectorに赤く表示する

こちらを導入するに当たり循環参照の検証処理も少し工夫したので共有します。

循環参照が起こるケース

Compositeパターンにおける循環参照は以下のケースがあります。

  • 自分自身への参照を持つ場合

f:id:tsgcpp:20210221022218j:plain

  • 葉要素(子孫)が自身への参照を持つ場合
    • 以下の例では Self と Leaf06 で循環参照

f:id:tsgcpp:20210220215252j:plain

  • 葉要素(子孫)同士が循環参照している場合
    • 以下の例では Leaf06 と Leaf07 が互いに循環参照

f:id:tsgcpp:20210220215435j:plain

循環参照の検証方法

以下の手順で行います

  • 葉要素がCompositeではないこと
    • Compositeパターンの葉要素は元となったinterfaceなのでダウンキャスト(as)でCompositeかどうかの判定が必要
  • 葉要素がCompositeである場合は自身でないこと
  • 自身ではない場合は先祖一覧の中に子要素がないこと
  • 自身を先祖一覧に登録
  • 先祖一覧を用いて子要素すべてで同様の検証を実施

上記をComposite Installer の子要素が見つからなくなるまで行うことで循環参照を検知することができます。

先祖一覧を記録しながら探索することになるため、可変Listが必要になります。

また、デザインパターンなどを考慮するとダウンキャストはアンチパターンになり得ますが、
今回は広く利用されることを想定し循環参照問題の回避を優先してダウンキャスト込みの検証方法にしました。

ちなみに検証コードは以下となります。

CompositeInstallerExtensions.cs

検証処理のアロケーションの削減

私は余計なアロケーションは極力避けたい性質なので、アロケーションをなくしたい欲が出ました。
一応、RuntimeだけでなくEditor上でもInspectorの更新毎に検証処理が動いてしまうため、ある程度アロケーションを回避したかったという理由もあります。

結論を述べますと 4連結までの検証はアロケーションが回避される ようになっています。

やり方は単純で先祖1つ、先祖2つ、先祖3つ、先祖4つで検証するメソッドを用意して連結するテクニック(つまり面倒くさいやり方)で対応しました!
見たほうが説明が早いと思いますので以下のコードを御覧ください。

CompositeInstallerExtensions.ValidateAsComposite

4連結まで対応したのは「多くても3連結ぐらいかな?」と思ったためです。

4連結以上の場合はアロケーションが発生しますので、「いやもうちょっとアロケーション発生しないようにしたい」って人は上記のコードを参考にPullRequestを出してください。

もちろん、テスト (TestCompositeInstallerExtensions) も追加してあげてください!

stackallocとかいうunsafeコードを使うことも一瞬考えたけど流石にすぐ却下しましたw

余談: 循環参照対策なしの場合

循環参照のあるComposite Installerに対してInstallBindingsをコールした場合、以下の現象が発生しました。

  • PlayではStackOverflowException or エディタ自体が落ちる
    • どちらになるかはPlay毎に異なり、StackOverflowException 確定ではありませんでした
  • Zenject Validate では落ちることなくフリーズし、エディタが入力を一切受け付けなくなる

どちらにしてもInstallBindings の初めに検証(Assert)を入れる必要がありました。

アロケーション発生させたくないとか言ってられない!

そもそもなぜ作ろうとおもったのか

  • Installer群を一括でContextに登録する機能がほしかったから
    • Installerが増えて管理しづらかったので
    • 1つのInstallerクラス内にすべてのBind処理を書くと保守性が悪化するため避けたかった
  • 開発におけるワークフローを考えてみたときにComposite Installerがあればいろいろと応用が効くと思ったから
    • 具体的には「Compsote Installer の活用術」で述べた通りです

他に良さそうな活用方法があればぜひ教えて下さい!

Composite Installerを試したい場合

9.2.0 にrebaseでComposite Installerのcommitを取り込んだタグを用意しました。
もともと拡張として作成していたこともあってExtenjectの基本機能に変更はありません(Inspector表示用のEditorに少し変更が入ったぐらいです)。

metaファイルも同じなのでリリースバージョンが出てもCompositeMonoInstaller, CompositeScriptableObjectInstallerはそのまま使えると思います。
MITライセンスなので言う必要はないと思いますが、自己責任でお願いします!

github.com

フォークしたリポジトリ(tsgcpp上)にタグを登録していますが、本体にはすでに取り込まれている内容なためtsgcppの記名は不要です。

クレジット

  • draw.io @diagrams.net
    • 図形の作成に利用させていただきました

雑感

まさかの3ヶ月ぶりの投稿です。。。
個人開発を優先していたらあっという間に月日が流れていました。

記事を書いた理由はそろそろ情報発信しないとなと思ったことと、
Extenjectを使ったワークフローの提案的なことをやってみたいと思ったからです。

Extenjectに限らずDIコンテナはゲームやアプリケーションを面白くするための仕組みではありませんが、
工夫次第で幅広いシステムに対応させることが可能になると思います。

UnityでSOLID原則などを取り入れる上でも欠かせない存在かな思いますので、ぜひ活用してください!

それでは~

【GitHub Packages for Unity】限定配布も可能, GitHub Packages で Unity アセット配布

f:id:tsgcpp:20210731231035j:plain

概要

今回はUnity向けにGitHub Pakcagesでアセットを配布する方法を紹介します。

社内やチーム内でUnityのパッケージを限定配布できたりもするので、
良かったら活用してみてください!

記事の最後にサンプルを記載しています。

記事内でのキーワード略称

  • GitHub Packages -> GHP
  • GitHub Actions -> GHA
  • Unity Package Manager -> UPM

GitHub Packages について

簡単に言えば「パッケージ公開サービス」でGitHubから提供されています。

github.com

様々なパッケージ形式に対応

  • npm, gradle, docker container などあらゆるパッケージ形式に対応

docs.github.com

GitHubの仕組みでパッケージのアクセス権限を制御可能

Publicは無料、Privateは従量課金制

  • Publicパッケージは無料で配布可能
  • Privateパッケージでも Free, Team, Enterpriseそれぞれにも無料枠有り
    • ストレージ枠とデータ転送枠が使用されます
  • billing ページにて使用状況を確認可能
    • Data transfer out がデータ転送枠

docs.github.com

GitHub Actions 経由でリポジトリからアップロード

  • Repository -> Actions -> Packages の流れでアップロード

今回は GHA での簡単なアップロード例を紹介します


アップロードまでの流れ

まずはパッケージをアップロードするまでを紹介します!

Package Manager形式でアセットを用意

  • UPM向けにアセットを用意
    • アセット + package.json
  • meta ファイルも必ず生成
    • UPMで必須、存在しないと取り込んだときエラー

サンプルコードにも簡単なパッケージ用アセットを4つ用意しています。

f:id:tsgcpp:20210731173145p:plain

package.json の name について

  • nameの推奨文字は a-z0-9.-_大文字は非推奨
  • com.<org>.<name>(.<sub name>) のようにドメインを含めること推奨
    • UPMで使用する際の Scoped Registriesドメイン部分でアクセス先を制御するため
    • jp.co, jp.ne などでも可
com.tsgcpp.unitygithubpackageexample.integration
com.tsgcpp.tscubemapgenerator
jp.co.tsgcpp.groupname.categoryname

package.json について

  • publishConfig 項目が必須
    • UserもしくはOrganizationの前には @ が必須
    • すべて小文字 (アカウント名がSampleAccの場合は@sampleacc)
    • パッケージをアップロード先として使用される
    • 余談: GitHub上ではアカウント名は tsgcppTsGCpP も同じ扱い
  • unity 項目もUnity向けのため必須
  • それ以外は通常のUPMと同様
"publishConfig": {
        "registry": "https://npm.pkg.github.com/@<user or organization>"
    }

サンプルの全体は以下となります

{
    "name": "com.tsgcpp.unitygithubpackageexample.integration",
    "version": "1.2.3",
    "displayName": "Integration Package Example",
    "description": "Integration Package Example for UnityGithubPackageExample",
    "unity": "2019.4",
    "keywords": [
        "tsgcpp",
        "main"
    ],
    "license": "UNLICENSED",
    "dependencies": {
        "com.tsgcpp.unitygithubpackageexample.script": "2.3.4",
        "com.tsgcpp.unitygithubpackageexample.prefab": "3.4.5"
    },
    "author": {
        "name": "tsgcpp",
        "url": "https://github.com/tsgcpp"
    },
    "scripts": {
        "test": "exit 0"
    },
    "repository": {
        "type": "git",
        "url": "git+https://github.com/tsgcpp/UnityGithubPackageExample.git"
    },
    "bugs": {
        "url": "https://github.com/tsgcpp/UnityGithubPackageExample/issues"
    },
    "publishConfig": {
        "registry": "https://npm.pkg.github.com/@tsgcpp"
    }
}

アップロード用アクションを定義

  • GHA の Yaml を定義
    • サンプルでは .github/workflows/release-package.yml に定義
  • packagePath にパッケージの相対パスを指定(複数可)
  • actions/setup-node のstepは必須
  • npm publish でパッケージをアップロード

せっかくGHAを使うので、アップロード処理前にテストも組み込みましょう!

name: Unity Example Packages Publish

# release tagを発行時にアップロード
on:
  release:
    types: [created]

jobs:
  # Unity Test Runner の実行 (GameCIのunity-test-runnerを利用)
  test:
    name: Test in ${{ matrix.testMode }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        projectPath:
          - .
        unityVersion:
          - 2020.3.14f1
        testMode:
          - all
    steps:
      - uses: actions/checkout@v2
        with:
          lfs: true
      - uses: game-ci/unity-test-runner@v2
        id: tests
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
        with:
          unityVersion: ${{ matrix.unityVersion }}
          projectPath: ${{ matrix.projectPath }}
          testMode: ${{ matrix.testMode }}
          artifactsPath: ${{ matrix.testMode }}-artifacts
          githubToken: ${{ secrets.GITHUB_TOKEN }}
          checkName: ${{ matrix.testMode }} Test Results
      - uses: actions/upload-artifact@v2
        if: always()
        with:
          name: Test results for ${{ matrix.testMode }}
          path: ${{ steps.tests.outputs.artifactsPath }}

  # アップロード処理の実施
  publish:
    needs: test
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        packagePath:
          - ./Assets/Plugins/Script
          - ./Assets/Plugins/Material
          - ./Assets/Plugins/Prefab
          - ./Assets/Plugins/Integration
    permissions:
      packages: write
      contents: read
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: 12
          registry-url: https://npm.pkg.github.com/
      - run: npm publish ${{ matrix.packagePath }}
        env:
          NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
  • 余談 .npmrc はUnityの場合は不要(node.jsプロジェクトではないため)

パッケージのアップロード

  • masterにアセット, package.json, GHAのYamlを追加
  • リポジトリの Releases ページの Draft a new release より release tag を発行
    • release tagの発行がアクションのトリガーのため
  • アクションが正常に完了すると Packages に登録される
    • 初回のみ反映されるまで若干時間がかかる可能性有り

f:id:tsgcpp:20210731212117p:plain

サンプルリポジトリは記事の最後に記載しています。


UPM経由でのパッケージのインストール

次はアップロードされたパッケージのUPM経由でのインストール方法を紹介します!

アクセストークンの発行

アクセストークンはAPI経由などでGitHubのコンテンツにアクセスするためのトークンになります。

  • GHPのアクセスにはアクセストークンが必要
  • public, private にどちらでも必要

アクセストークンの注意点

発行する前にアクセストークンの注意点についてです。

  • トークンの発行はパッケージにアクセスする本人のアカウントで実施
  • 発行したトークンは第三者へは非公開にし、基本的に本人のみ使用する
  • (任意)期限 (Expiration) を指定して定期的に更新 (セキュリティ観点)

f:id:tsgcpp:20210731213012p:plain

GitHub上でアクセストークンを発行

f:id:tsgcpp:20210731222321j:plain

  • Note 項目に任意の名前を指定 (ex: Package Access)
  • (任意) 期限(Expiration)を設定
  • read:packages をチェック
    • パッケージアクセスのみであれば read:packages のみで問題有りません

f:id:tsgcpp:20210731213813p:plain

  • ページ下部の "Generate token" ボタンで発行
  • 発行されたトークンをコピー
    • ページを閉じると再確認できません!

f:id:tsgcpp:20210731214220j:plain

.upmconfig.toml ファイルを作成

[npmAuth."https://npm.pkg.github.com/@<user or organization>"]
token = "<ACCESS TOKEN>"
alwaysAuth = true

以下は記入イメージ

[npmAuth."https://npm.pkg.github.com/@tsgcpp"]
token = "ghp_hogehogehogehogehoge"
alwaysAuth = true

これでUnityのパッケージインストール時にアクセストークンが使用されます

Unity で Scoped Registries とインストールパッケージを設定

  • Packages/manifest.json をエディタで開く
  • scopedRegistries 項目に記載
    • url には https://npm.pkg.github.com/@<user or organization>
    • scopesドメイン部分を記載

以下は記入例で、com.tsgcppとついたパッケージは GHP の @tsgcpp 経由で取得されます。

{
  "scopedRegistries": [
    {
      "name": "tsgcpp public",
      "url": "https://npm.pkg.github.com/@tsgcpp",
      "scopes": [
        "com.tsgcpp"
      ]
    }
  ],
  "dependencies": {
    "com.tsgcpp.unitygithubpackageexample.integration": "1.2.3",
    ...
  }
}

補足: com.tsgcpp.unitygithubpackageexample.integration (サンプルパッケージ) の依存グラフ について

  • 今回 integration, script, prefab, material のパッケージは以下の依存関係
  • com.tsgcpp.unitygithubpackageexample.integration のみの取り込みで他3パッケージも取得

f:id:tsgcpp:20210731220943j:plain

インストールの確認

  • Unityエディタ上で Packages 項目を確認しましょう

f:id:tsgcpp:20210731221437p:plain


以上がGHP経由での取込までの流れとなります!

ちなみに紹介したとおりに .upmconfig.tomlmanifest.json を設定すれば、
私が用意したサンプルパッケージ (public) が取り込まれます。

残りは GHP を使用する場合のメリット、デメリットを紹介します!


GitHub PackageでUnityパッケージを配布するメリット

package.json の dependencies に依存を定義可能

  • GHPにアップロードされたパッケージは dependencies 項目で依存パッケージを指定可能
  • UPMで取り込んだ場合は依存パッケージも合わせて取り込まれる
    • Git URL 経由では難しかった依存パッケージの同時取込が可能
{
    "name": "com.tsgcpp.unitygithubpackageexample.integration",
    "version": "1.2.3",
    "displayName": "Integration Package Example",
    "description": "Integration Package Example for UnityGithubPackageExample",
    "unity": "2019.4",
    ...
    "dependencies": {
        "com.tsgcpp.unitygithubpackageexample.script": "2.3.4",
        "com.tsgcpp.unitygithubpackageexample.prefab": "3.4.5"
    },
    ...
}

Privateリポジトリでのパッケージ配布が容易

補足: Git URLでもPrivateリポジトリから取り込みは一応可能

最近のUnityではGit URL経由の場合も、専用のアクセストークンを勝手に作ってくれたりします。

ただ、アクセストークンの削除が面倒だったり、WindowsMacで動作が異なったり、
チーム内での厳密な運用には若干向かない印象があります。

GitHubアカウントの仕組みでアクセス権限を制御可能

  • GitHubManage Access でパッケージのアクセス範囲を制御可能
    • 限定配布が容易になる理由の1つ

独自のnpmレジストリが不要

  • privateな独自のregistryの作成、保守、運用が不要


GitHub PackageでUnityパッケージを配布するデメリット

Unity Package Manager のパッケージ検索機能が使用不可

  • GHP側に検索API endpoint (/-/all, /-/v1/search)が提供されていない
  • UPMでパッケージ検索が発生すると毎回エラーログが出てしまう
    • UPMウィンドウを開いたときなどに scopedRegistries に登録されたレジストリ全体で検索が実施される仕様のため

f:id:tsgcpp:20210731165326p:plain

ビルド自体には影響しません

Packages以下で displayName で表示されない

パッケージの使用自体は問題ない (と思います)

  • 原因不明、検索機能が使えないことが原因?
  • displayName 自体は認識されている
  • Add package from git URL... の場合は問題なく displayName で表示される

f:id:tsgcpp:20210731171050p:plain

f:id:tsgcpp:20210731171836j:plain


GitHub Package を使用する場合の諸注意

  • privateの場合はストレージ枠に注意
    • アップロードされたパッケージ毎にストレージ枠を消費
    • 巨大なファイルをアップロードする場合は特に注意 (3Dモデル, サウンドファイル、動画ファイルなど)
  • パッケージ削除は一応可能ですが、基本的に削除しない方針を推奨
    • 作業コストとヒューマンエラーにつながる
    • 特にパッケージが大量になったときの手作業は担当者が地獄を見ます

おサイフと相談しましょう!ご利用は計画的に!

サンプル

サンプルリポジトリ

github.com

サンプルパッケージ

github.com

サンプルパッケージ発行時のアクション

github.com

参考

forum.unity.com

雑感

GitHubにサンプルを作成してからちょっと遅れてしまいました。

今週はちょっとドタバタしていたので、今日は集中して記事にしてみました!

GHP経由の場合は検索機能がありませんが、依存パッケージを定義できたりアクセス制御もGitHubの仕組みを流用できたりで運用上もメリットがあると思います!

あと、displayNameが反映されない問題は気になるのであとでUnityに報告しておきます(公開パッケージがあったほうが報告しやすかったので)。

それでは~

【感想】 Adaptive Code のススメ

f:id:tsgcpp:20210704153046j:plain

概要

今回は 「Adaptive Code C#実践開発手法 第2版」を読み終わったので、
そこで得た知見から特に感銘を受けた部分を3つ共有しようと思います。

www.nikkeibp.co.jp

Unity開発者なので、UnityとC#を題材にして紹介します。

本当は数か月前には読み終わってたんですが、記事にできていませんでした。。。復習の意味も込めて記事にします。

知見

リスコフの置換原則 (Liskov Substitution Principle) の保証

以下 LSP と呼称します。

LSPの概要

平たく言うと
"サブクラス、継承クラスはベースクラスに置き換え可能でなければならない"
というSOLID原則の1つですね。

本には「コントラクト(ルール定義)」として事前条件、事後条件、データ不変条件などといった重要な要素も紹介されています。

この本から得られたのは単なるリスコフの置換原則の知識ではなく、

如何にしてリスコフの置換原則を保証するか

という部分でした。

LSPの保証方法

結論を述べるとテストクラスを活用します

  1. ベースのテストクラスを定義
  2. 継承クラスのテストはベースのテストを継承

つまり、単に「LSPを満たすように実装する」だけではなく、
「LSPを機械的に保証する」をテストクラスで実現することになります。

LSP保証のテストクラスのサンプル

テスト対象のinterface

今回の題材は「数値の丸め処理を行うメソッド」とします。
以下の IRounder.Round の事前条件、事後条件をベースのテストクラスで定義しましょう。

public interface IRounder
{
    float Round(float value, float interval);
}

ルール定義

  • 事前条件
    • intervalに0は指定不可
    • intervalに負の値は指定不可
  • 事後条件
    • 引数のvalueとintervalが同じ値の場合はintervalを返すこと

ベースのテストクラスを定義

ベースのテストクラスのポイントとして

  • abstractクラスとして定義
  • abstractメソッドとして生成メソッドを定義
  • ルールを満たすことを確認するテストを定義
using NUnit.Framework;

public abstract class TestRounderBase
{
    // abstract指定により対象オブジェクトのインスタンス生成を継承テストクラスで定義
    public abstract IRounder CreateTarget();

    IRounder _target;

    [SetUp]
    public void SetUp() => _target = CreateTarget();

    // 事前条件1: intervalに0は指定不可として、例外を出すこと
    [Test]
    public void Round_ThrowsExceptionIfIntervalIsZero()
    {
        Assert.Throws<RounderException>(() =>
        {
            _target.Round(1f, interval: 0f);
        });
    }

    // 事前条件2: intervalに負の値は指定不可として、例外を出すこと
    [Test]
    public void Round_ThrowsExceptionIfIntervalIsLessThanZero()
    {
        Assert.Throws<RounderException>(() =>
        {
            _target.Round(1f, interval: -1f);
        });
    }

    // 事後条件1: 引数のvalueとintervalが同じ値の場合はintervalを返すこと
    [Test]
    public void Round_ReturnsIntervalIfValueIsEqualToInterval()
    {
        Assert.AreEqual(1f, _target.Round(1f, interval: 1f));
        Assert.AreEqual(2f, _target.Round(2f, interval: 2f));
        Assert.AreEqual(10f, _target.Round(10f, interval: 10f));
    }
}

継承クラスのテストを作成

ベースクラスを継承したテストクラスを作成。
ベースクラスを継承して、継承クラスのインスタンス生成メソッドを定義する。

public class TestNearestRounder_LSP : TestRounderBase
{
    public override IRounder CreateTarget() => new NearestRounder();
}

ちなみに NearestRounder の定義は以下です。

using System;

public sealed class NearestRounder : IRounder
{
    public float Round(float value, float interval)
    {
        if (interval <= 0f)
        {
            throw new RounderException("\"interval\" is 0 or less");
        }

        return (float)Math.Round(value / interval) * interval;
    }
}

テスト全体は TestNearestRounder.cs で確認できます。
また、こちらのサンプルには NearestRounder特有のテストも別クラスとして記載しています。

テストの実行

IRounderの継承クラスがLSPを満たしていることをUnitTestで確認。

f:id:tsgcpp:20210704120333p:plain

また、以後ルールを追加したい場合はベースクラスに追加することで、
継承クラスも常にルールを満たしていることが検証されるようになります。

LSPのまとめ

ベースとなるテストクラスでLSPのルールを定義することで、より強固なLSPを守るフローを構築することが可能になります。

interfaceのコメントに「intervalは0以上を必ず指定させること」などを記載してもプログラム上では何の影響もありませんが、 テストクラスを再利用することで保守する上でも保証することが可能になります。

abstractなテストクラスを利用する発想があまりなかったので、目からうろこでした。

投機的な一般化(Speculative Generality)

書籍には "投機的な一般化" と記載されていましたが文字だけだとなんのこっちゃ感がありますね(笑)。

原文は"Speculative Generality"です。

ちなみに書籍では「開放/ 閉鎖 の 原則」を意識するための要素として紹介されています。

投機的な一般化の概要

ここは私自身の解釈も含まれますが、端的に言えば

"あると良さそうと思って拡張可能に実装した、でも結局使わなかった"

的なことです。

interfaceを使った抽象化を身に着けたばかりのプログラマーによくある行動として、
「あらゆるものをinterface化して、意図しない拡張性を持たせてしまう」というものがり、これが投機的な一般化となる可能性があります。

「すべての設計は明確な意図をもって行うべき」との説明があり、
投機的な一般化はそれに反する行いのことです。

予想されるバリエーション

「予想されるバリエーション」とは「何が拡張できて、何が拡張できないのかを明確にすべきである」と説明があります。
投機的な一般化の対比となり、設計は「投機的な一般化」ではなく「予想されるバリエーション」となるようにするべきということになります。

投機的な一般化の問題

  • 拡張ポイント特有の実装が無駄になってしまう
    • interfaceで抽象化されたメソッドを使用する場合、そのメソッドのあらゆる結果の対策が必要となる
    • 例: そのメソッドが異常系を返すパターン、例外を出すパターンなどの考慮が必要となり、結果的に開発コストが高くなる。
  • 意図しない継承クラスが作成されてしまう可能性がある
    • 継承クラスが作成された場合は継承クラス特有の考慮が必要になる
    • アセンブリ(Unityにおけるasmdef)に継承クラスが分散し、むやみにinterfaceを変更できなくなる

投機的な一般化の回避例

修飾子(sealed, internalなど)を指定

修飾子はコンパイラが解釈する要素となります。
つまりクラスのルールをコンパイルレベルで定義することが可能になります。

  • クラスにsealedを指定し、派生クラスの作成を制限
    • もし派生クラスを想定していないのであれば基本的にsealedを付けて保守範囲を限定する
    • 余談: C#はデフォルトで非sealed (派生クラスの作成可能)
  • クラスにinternalを指定し、異なるアセンブリでの派生クラス発生の回避
    • internalクラスはアセンブリ内でのみアクセス可能なので、自分の管轄外での派生クラスの発生を回避できます
    • 余談、もしinternalクラスのUnitTestをしたい場合は InternalsVisibleToAttribute を活用すると良いです

ちなみにKotlinだとデフォルトでは派生不可(非open)で、僕がKotlinを好きな理由の1つだったりします。

プロダクトの拡張ポイントを見極める

ここはノウハウや経験に基づくものになります。

「投機的な一般化」は拡張部分に焦点を絞ったテーマのため少し話はずれてしまいますが、
以下のことを意識してみるとよいかもしれません。

  • もし自身が企画者である場合は「この拡張が必要な理由が明確になっている」かを考慮する
  • もし自身が実装する立場であった場合は、企画した人が「とりあえずあると良さそうな部分を提案する」といった性質がある場合は、そのアイデアを深堀して必要性を明確化する

「15分程度で終わる議論をせず、5日かけて実装したけどやっぱりいらなかった」みたいなことは極力避けましょう。

拡張ポイントを作るということは、それだけ保守範囲も増えるということを意識すると良いと思います。

投機的な一般化のまとめ

「すべての設計は明確な意図をもって行うべき」という言葉にはなかなか考えさせられるものがありました。

意図をしない部分があるということは、言ってしまえばあらゆることが起こってしまうと可能性を秘めているということです。
だからこそノウハウを蓄積して、予測できなかった事象を少しでも減らしていきましょう。

私もクラスを作るとき、派生を想定していない場合は sealed を忘れないようになど小さなところから意識するように心掛けていきたいです。

コナーセンス(Connascence)

コナーセンスの概要

コナーセンスとは「コードにおける依存度合(結合度)を図る指標」のことです。
依存関係の尺度を見極めるうえで意識すると役立つ概念となります。

コナーセンスにはレベルがあり、高いものほど処理との結合度が強いということになります。

コナーセンスの結合度

結合度を考える場合に簡単な判断として、
「ある要素を変更した際の影響度合い」と考えても良いと思います。

具体例を2つほど出します

名前のコナーセンス

"名前" のコナーセンスは、結合度が高いです。

ここでいう名前とは、クラス名やメソッド名など"コンパイラが解釈する名前"のことです。
注意として、メソッド名に使用されている単語自体の意味は関係ありません

とあるメソッド名を変更したときは、そのクラスを使っていた部分も修正しないとコンパイルエラーが発生しますよね?

    public float Calc() => ...

    public void Main()
    {
        var result = Calc();
        ...
    }

上記のコードで、もし Calc() -> Calculate()とリネームした場合は、Main関数内も修正が必要になります。
クラス名やメソッド名は名前を変えると、それだけで使用者に影響を与えるため強い結合度を持っていると言えます。

値のコナーセンス

"値" のコナーセンスは、結合度が低いです。

これは身近ないい例があります。
よく「マジックナンバーは定数化しましょう」みたいなことを聞きませんか?

なぜ定数化するかというと、定数名に意味を込めて意図が伝わりやすいようにするためという側面もあります。
(もちろん、共通の定数を再利用するといった理由もあります)

    public float CalcArea(float radius) => radius * radius * 3.141592f;

例えば上記のように3.14...とみると大体の人は「円周率だ!」となると思います。
しかし、すべての値がそれだけで意図が伝わるとは限りません。

    public int CheckCode(int code) => code == 200 ? 0 : -1;

上記のコードは見慣れた人には「ああ、HTTPステータスかな?」となると思いますが、
見慣れない人には人には暗号のように見えると思います。

コードを書いた人は「HTTPステータスコードによってエラーコードを返すようにした」つもりで書いても、
三者には伝わらりづらいコードになるかもしれませんので、以下のように定数名に意味を込めてあげましょう。

    private const int HttpOk = 200;
    private const int ValidValue = 0;
    private const int InvalidValue = -1;

    public int CheckCode(int code) => code == HttpOk ? ValidValue : InvalidValue;

プログラム的にも 200 -> 100 に置き換えても別にコンパイルは通ってしまいますが、
それだと意図しない挙動になってしまいます。

値のコナーセンスのように結合度の低い場合は、定数化するなどの工夫が求められることが多いです。

コナーセンスのまとめ

コナーセンスはコード自体の品質を図るうえで非常に役立つ指標になります。

書籍には「インターフェイスのコナーセンス」という非公式なコナーセンスが紹介されています。
インターフェイスは継承するオブジェクトの必須要素やクライアントの依存部分を定義することが可能で、
意味を込めるうえでは非常に有用な要素といえます。

書籍を読むまではコナーセンスという単語自体は知りませんでしたが、
「interfaceで要素を定義する」などは意識していたこともあって、より具体的な概念として知ることができました。

コナーセンスの要素として10個ほど紹介されており、それぞれに特有の観点がありますので気になったら確認してみてください!

サンプル

LSPについてのサンプルコードは以下にあります。

github.com

雑感

今回は技術書の感想という形で記事にしてみました。

こんな1ページの記事では紹介しきれないぐらいたくさんの知見について記述されています。

何気にアジャイル開発やスクラムについても簡易的に紹介されていますので、
アーキテクチャやコーディングだけでなく、ワークフローについても勉強になる本だと思います。

SOLID原則をより深堀したい人にはとてもオススメなのでよかったら手に取ってみてください!
それでは~

【Unity】OculusIntegrationがPackageManager対応しやすくなりました!

概要

Oculus Integration SDK がいつの間にかUnity Package Managerに対応させやすくなっていたのでちょっと紹介します。

巷では、Oculus Quest向けのSplash Screen が指定可能になったことで話題でしたが、何気にPackageManager対応しやすくなったのも見過ごせないと思っています!

※Splash Screenについての公式ツイート

環境

  • Oculus Integration SDK 28.0 (1.60) 以降

追記

  • 2022/10/12
    • Meta公式によるPackage Manager対応

Meta公式によるPackage Manager対応 (2022/10/12 追記)

Meta公式からMeta XR Integration (旧Oculus Integration) v44 から Pacakge Managerによるインストール方法がアナウンスされています。

Import Individual SDKs from Unity Package Manager | Oculus Developers

レジストリhttps://npm.developer.oculus.com/ とのこと。

packages are hosted on Meta’s scoped registry: https://npm.developer.oculus.com/. 

現時点では基本機能 (Oculus/VR) のみのようですが、順次対応されるようです。

How Unity Package Manager Can Help You Manage Integration SDKs

Meta Quest Proの発表もあったので、対応が進められたのかもしれません。

こちらの記事を公開してから1年4ヶ月は経ってるので、あまり「そのうち」ではありませんでしたね(笑)

PackageManager対応方法

1. Oculus Integration パッケージ化専用のUnityプロジェクトを作成

  • パッケージ用のUnityのプロジェクトを作成

2. Oculus Integrationをインストール

  • Asset Store or アーカイブページ からOculusIntegrationをダウンロードおよびインストール
    • 必要なアセットにチェックを入れて "Import"

※今回は基本機能のみをインストールするため Oculus/VRのみ指定

3. gitignoreにOculus向け設定ファイルを追加

  • commitから除外するために.gitignoreにOculus向け設定ファイルを追加
    • パッケージ対応したOculusIntegrationを取り込むと自動的に生成されます
    • パッケージ側からは除外しておきましょう
# Oculus Assets
/[Aa]ssets/[Oo]culus/OculusProjectConfig.asset
/[Aa]ssets/[Oo]culus/OculusProjectConfig.asset.meta
/[Aa]ssets/Resources/OVRPlatformToolSettings.asset
/[Aa]ssets/Resources/OVRPlatformToolSettings.asset.meta

4. package.json を作成

  • Package Managerに対応させるため Assets/Oculus/package.json を作成
    • nameなどはプロジェクトに合わせて適宜修正してください
{
    "name": "com.tsgcpp.oculusintegration",
    "version": "1.61.0",
    "displayName": "OculusIntegration",
    "description": "The Oculus Integration SDK (private).",
    "unity": "2019.4",
    "keywords": [
        "oculus"
    ],
    "dependencies": {}
}

5. Assets/Oculusをcommit

  • .gitignore を commit
  • Assets/OculusAssets/Oculus.meta をcommit
    • package.jsonも併せてcommit

6. Private用のGitリポジトリにpush (任意)

※Publicにすると再配布になるため注意が必要

  • Private用のGitリポジトリにpushしましょう
    • Gitリポジトリにpushすることで、Git経由でPackage Managerで取り込みが可能となります

フォルダ構成

パッケージの取り込み方

  • 通常のPackageManagerでの取り込みと同様となります

Add package from git URL...

  • https://<git url>/<org>/<repository>.git?path=Assets/Oculus#<tag or branch> のように指定
    • i.e. https://github.com/tsgcpp/OculusIntegration.git?path=Assets/Oculus#1.61.0 (リポジトリ名: OculusIntegration, タグ名: 1.61.0 の場合)

Add package from disk...

  • Assets/Oculus/package.json を指定

取り込み後に設定ファイルが生成される



対応しやすくなった理由

そもそもなぜ対応が難しかったのか

Oculus Integration SDK 27.0 (1.59) 以前の話となります

  • 原因は Assets/Oculus/VR/Editor/OVRProjectConfig.cs
    • OculusProjectConfig.asset(Oculus向けの設定アセット) を自動生成するEditorスクリプト
  • 自動生成するパスの指定方法に問題があり、PackageManager経由で取り込んだ場合にエラーが発生
    • 端的に言うとOVRProjectConfig.csの親の親フォルダが生成先
  • PackageManagerで取り込んだ場合はAssets/以下ではなく、Packages/以下が生成対象となってしまっている
    • Packages/com.tsgcpp.oculusintegration/VR/Editor/OVRProjectConfig.cs に配置されているため
    • AssetDatabase.CreateAssetでScriptableObject(OVRProjectConfig)の生成先にPackages/以下を指定するとエラー (Assets/以下ではない場合にエラー)

暫定対応

Oculus Integration SDK 27.0 (1.59) 以前の話となります

  • Assets/Oculus以下のEditorフォルダすべてをunitypackageにExport
  • Assets/Oculus以下にあるEditorフォルダ以下をすべて削除
  • OculusIntegrationを使用するメインプロジェクトに上記unitypackageでEditor群を取り込み
    • OculusProjectConfig.assetを使用するにはEditor以下のスクリプトが必要なため

上記の方法で暫定的に対応することは可能でした。
ただ、Editorスクリプトのみ直接取り込む形となるため、結構残念な感じになってました。

PackageManagerで取り込まれた場合の考慮が28.0以降で追加

  • SDK 28.0 (1.60) で以下の考慮が追加されました
  • OVRPluginUpdaterStub.IsInsidePackageDistributionassetPath.StartsWith("Packages/" の判定を実施
  • Packages/以下だった場合は、Assets/Oculus/OculusProjectConfig.asset を生成先に変更
    private static string GetOculusProjectConfigAssetPath()
    {
        ...

        if (OVRPluginUpdaterStub.IsInsidePackageDistribution())
        {

つまり、PackageManagerで取り込まれた場合でもAssets/Oculus以下に設定ファイルが生成されるようになりました。
これがOculus IntegrationがPackageManager対応しやすくなった理由です!

PackageManager対応するメリット

「別に直接プロジェクトに取り込んでもよくない???」って思った方もいらっしゃると思いますので、
メリットについても補足しようと思います。

  • OculusIntegrationを管理するGitリポジトリを分割可能
    • GitHubなどの容量制限にひっかかるのを回避可能
    • OculusIntegrationは単体でもかなり容量が大きく、追加するアセットによってはあっさり100MBを超えます
    • Assets/Oculus/VRのみでも60MBあります (29.0の場合)
  • アセットのバージョン管理と更新が容易
    • Package Managerでアセットを取り込むと更新が容易になります(バージョンを更新するのみ)
      • Asset Store(unitypackage)で取り込んだ場合、一度アセットをすべて削除する必要がある場合が多い
    • hookされるEditorスクリプトがあるため再起動はどちらにしても推奨

雑感

今回はOculus関連にテーマを絞ってみました。
Oculus Quest2がかなり売れているので、OculusIntegrationの更新もかなり加速している感じがします。

もしかしたら、そのうちUnity Package ManagerでOculus Integrationが提供されるのかもしれませんね。

Unity向けだけじゃなくて、Unreal向けも加速しているような気がします。
Splash Screenも追加できるようになったので、これを機に追加しても良さそうですね。

みなさんのOculus向けアプリケーション開発の助けになれば幸いです。
それでは~。

【Unity】Physics.SyncTransforms の特性調査

今回は Physics.SyncTransforms の特性を調査したため共有します。

経緯

  • Colliderの物理エンジンへのFlushを 通常のタイミングとは別で実施したい
  • Flushを任意のタイミングで実行するにはPhysics.SyncTransforms をコールする必要があった
  • Physics.SyncTransformsを通常のFlushとは別に実施する場合にパフォーマンスなどが気になったため調査

※Flushとは実際にデータを反映する処理のこと(ここでは物理エンジン用メモリ領域への反映)

検証環境

  • Unity 2020.3.4f1
    • Mono
    • .Net Standard 2.0
  • Physics.autoSyncTransforms = false
  • Oculus Quest 2 (Android)
  • Development Build 有り(Profiler用)

前知識

物理オブジェクトの物理エンジンへのFlush

Physics.SyncTransforms

  • 上記「物理オブジェクトの物理エンジンへのFlush」を任意のタイミングで発動させるメソッド
  • 今回の主役

Physics.autoSyncTransforms

  • ColliderのTransformが変更された際に即座に物理エンジンに反映するかの設定
  • Unity 2019以降はデフォルトでfalse
    • つまりデフォルトではFixedUpdate後にFlushが実施されると同義
  • ProjectSettingsのPhysicsからも設定可能

When autoSyncTransforms is set to true, repeatedly changing a Transform and then performing a physics query can cause performance loss.
  • Physics.autoSyncTransforms = trueにする場合はオーバーヘッドに注意


今回は Physics.autoSyncTransforms = false の場合をメインに調査

Flushの発生条件

  • ColliderのTransformが変更された場合
    • Dirtyフラグが付き、後述の「Flushの発生タイミング」で物理エンジンに反映
  • こちらのUnity Pro Tips によれば移動する物理オブジェクトがない場合はオーバーヘッドはほとんどないとのこと
    • つまり移動しないオブジェクトに関しては static かどうかにかかわらず Flush が発生しない

Flushの発生タイミング

  • Physics.autoSyncTransforms = false であれば FixedUpdate後 or Physics.SyncTransforms コール時点
    • Dirtyフラグが付いた物理オブジェクトのみ
  • Physics.autoSyncTransforms = true であればColliderのTransformが変更された時点


-- ここまでが前知識 --


登場人物

PlayerColliders

  • CapsuleCollider + BoxCollider 64個 (4 x 4 x 4) をアニメーション
  • LateUpdate(Animation Update後として) にて Physics.SyncTransforms を実施し時間を計測
  • すべての検証環境で使用

www.youtube.com

AnimationColliders

  • BoxColliderを大量(64000個)に配置してプレイヤー同様にアニメーション
    • AnimatorのUpdate Mode は Normal

www.youtube.com

PhysicsAnimationColliders

  • AnimationColliders の Update Mode を Animate Physics に変更したもの

DisabledAnimationColliders

  • AnimationCollidersのコライダー群のルートのGameObject(ColliderRoot)をオフにしたもの

DisabledAnimationColliders + CapsuleCollider

  • DisabledAnimationCollidersのColliderRootと同階層に1つのCapsuleColliderを配置したもの

ColliderDisabledAnimationColliders

  • ColliderRoot以下のすべてのColliderに対してCollider.enabled = false にしたもの
    • ColliderRootオブジェクト自体はアクティブのまま

Static Colliders

  • 10000個のstaticのBoxCollider群


検証方法

  • 後述する「登場人物」を組み合わせた状態で Physics.SyncTransforms をコールして実行時間の変化を計測
    • コールはLateUpdate時 (Animatorが終了した後として)

検証結果

※おおよその平均

注目したい部分は色付き

検証対象 時間 (ms)
PlayerColliders x1 0.07
PlayerColliders x2 0.10
PlayerColliders x4 0.19
PlayerColliders x1 (Animator disabled) 0.00
PlayerColliders + AnimationColliders 21.01
PlayerColliders + PhysicsAnimationColliders 0.07
PlayerColliders + DisabledAnimationColliders 0.07
PlayerColliders + DisabledAnimationColliders + CapsuleCollider 0.41
PlayerColliders + ColliderDisabledAnimationColliders 0.08
PlayerColliders + StaticColliders 0.07

クリックで展開:各調査ごとのProfiler

PlayerColliders x1

PlayerColliders x2

PlayerColliders x4

PlayerColliders のみ (Animatorをオフ)

PlayerColliders + AnimationColliders

PlayerColliders + PhysicsAnimationColliders

PlayerColliders + DisabledAnimationColliders

PlayerColliders + DisabledAnimationColliders + CapsuleCollider

PlayerColliders + ColliderDisabledAnimationColliders

PlayerColliders + StaticColliders

注意事項

  • 今回は Physics.SyncTransforms に主眼を置くためCPU Usageはあまり考慮しない
  • XR環境のためCPU Usageに表示されている時間の大部分は Gfx.WaitForRenderThread

判明した特性

  • Physics.SyncTransformsの時間はコール時点のFlushされていないオブジェクト数に依存
    • FixedUpdate直後からPhysics.SyncTransformsをコールするまでにTransformが変更されたオブジェクトが対象
  • Flush済みのオブジェクトはPhysics.SyncTransformsの時間には大して影響はしない
    • つまりTransformが変更されたオブジェクトがなければオーバーヘッドは小さい
  • 通常Flushとそれ以外でFlushする対象は分けることは一応可能
    • ただし「特定のオブジェクトのみFlushしたい」の様なコントロールは不可
      • Unity側に機能が用意されていない(と思われる、ご存じの方がいたら教えてください!)
  • 非アクティブなCollider(コンポーネントのenabled=false含む)はFlushの対象外
    • アクティブ化した後のFixedUpdate後 or Physics.SyncTransforms で改めてFlush
  • 同階層にアクティブと非アクティブ両方のColliderが存在する場合は特殊なFlushとなる可能性あり(詳細は不明)
    • "DisabledAnimationColliders + CapsuleCollider" の時間を見るとCapsuleCollider1つだけFlushされるにしては時間がかかっている

考察

  • Physics.SyncTransforms 時にFlushされるCollider数をうまくコントロールすれば、パフォーマンスへの影響をコントロールできそう
  • UpdateやFixedUpdateなで移動するColliderが多い場合は実行時間に注意が必要になると思われる
    • あまりに多いと Physics.SyncTransforms 時スパイクが発生

検証プロジェクト

github.com

雑感

ちょっと個人的な事情があって検証、ついでにドキュメント化してみました。
Flush対象を明示的に指定できないのはちょっと残念ですね。。。
ただ、物理で動くものとそれ以外で分けるのは一応できそうってとこでしょうか。

また、別の機会に Physics.SyncTransforms を利用したものを作ってみようと思います。

それでは~