メインコンテンツまでスキップ

2026-10-20 ワールドを Basis に移植する際の注意点

念のため書き添えますが、以下の文章はすべて手書きによるものです。

いくつかの結論は、ワールドの移植作業の一環として AI エージェントが遭遇した挫折から導き出されています。

全般​

  • Basis は異なるレイヤーを使用している可能性がある:
    • レイヤー設定が正しくない RenderTarget カメラでは、アバターやオブジェクトが映らないことがあります。
    • これは RenderTarget カメラ、リフレクションプローブ、ミラー設定、レイキャスト、およびその他の物理システムに影響する可能性があります。
  • 複数のプレイヤーが同じ名前を持つ可能性がある:
    • 表示名は一意ではないため、インデックスとして使用することはできません。
    • 同様に、サーバーが複数クライアントでの参加を許可していることが多いため、複数のプレイヤーが同じ DID キーを持つ可能性があります。
  • プレイヤー名にタグが含まれている可能性がある:
    • プレイヤー名にカスタマイズされたタグが含まれている場合があり、TextMeshPro などのリッチテキストフィールドでの表示が難しくなることがあります。
  • デフォルトでは、Interactable はトリガーではなく、VR コントローラーのグリップ(Grab)で起動する:
    • デフォルトでは、Interactable とのインタラクションにトリガーキーは使用されません。
  • Constraint は自動的に Basis Constraint に変換される:
    • これにより、元の Constraint を参照しているシステムが失敗することがあります。

ゲームプレイへの影響​

  • ジャンプの高さはアバターの身長に比例する:
    • 小さなアバターを使用しているプレイヤーは、固定のワールド空間ジャンプ高さを想定して設計されたプラットフォームを飛び越えられないことがあります。
    • 大きなアバターを使用しているプレイヤーは、固定のワールド空間ジャンプ高さを想定して設計された障害物を飛び越えてしまうことがあります。
  • コライダーはアバターの身長に比例する:
    • 本来なら歩いて渡れるはずの小さな隙間から、小さなアバターのプレイヤーが落下してしまうことがあります。
    • 本来なら落下するはずの割れ目を、大きなアバターのプレイヤーが平然と歩いて渡ってしまうことがあります。

仕様が不明確な点​

  • 1 フレーム内に複数回テレポートすると、テレポート後の位置が不正確になる可能性がある:
    • テレポート直後にプレイヤーの位置を取得しても、テレポート前と同じ値が返されることがあります。
    • この副作用により、2 回目のテレポート後の最終的な位置が正しくなくなる可能性があります。
  • VR プレイヤーの位置が不明確な場合がある:
    • VR プレイヤーの位置を取得しても、ロコモーションコライダーの最下部の位置が反映されないことがあります。
    • 代わりに、アバターのキャリブレーション時の初期プレイエリア位置が反映される場合があり、完全にオフセットしている可能性があります。
  • ローカルプレイヤーのトリガーコライダーが不明確:
    • ローカルプレイヤーが衝突イベントで使用できる信頼性の高いトリガーコライダーを持っているかどうかは不明です。

ビジュアル​

  • URP 対応を謳うシェーダーが、必ずしも Adaptive Probe Volumes をサポートしているとは限らない:
    • Basis はデフォルトで URP と Adaptive Probe Volumes を使用しており、ライトプローブ(Light Probes)は使用されません。
    • URP 対応を謳うシェーダーであっても、Adaptive Probe Volumes をサポートしていない場合があり、その場合は光を正しくサンプリングできません。
  • デフォルトのフォントマテリアルがすべてを透過して表示される:
    • この問題を避けるには、新しいフォントマテリアルを作成し、Text や TextMeshPro コンポーネントに割り当ててください。
  • 面積ゼロの三角形がプローブを壊す可能性がある:
    • ライトプローブを使用する動的オブジェクトが異常に明るくなったり、ワールドに四角いアーティファクトが発生したりする場合、エミッシブマテリアルを持つワールドメッシュに面積ゼロの三角形が存在する可能性があります。
    • これは通常、URP への移行に伴い Bakery ライトマッパーから離れる際に発生することがあります。
  • マテリアルに影響を与える Animator やコードは URP 用に更新する必要がある:
    • URP マテリアルのプロパティは、BIRP 時代のものとは異なるプロパティ名でアニメーションさせたり設定したりする必要がある場合があります。

Cilbox VM​

  • UI ボタンはスクリプトを直接呼び出すのではなく、コールバックを登録すべき:
    • イベントをトリガーするには button.onClick.AddListener(OnEraseAllClicked) を使用してください。
  • テスト済みでない限り、bool 配列の使用は避ける:
    • bool 配列は Cilbox でいくつかのバグを引き起こすようで、現時点では避けるべきです。
    • エラー例:
      • Privilege failed for System.Array.Empty generic argument 0 type
      • CilboxException: Error: Could not find reference to: [mscorlib][System.Array]
  • テスト済みでない限り、Vector4 から Vector3 への暗黙のキャストは避ける:
    • エラー例:
      • AmbiguousMatchException: Ambiguous match found

AI エージェントによる支援​

以下は、AI エージェントを使用して移植を統括しようとする場合にのみ適用されます。

  • 元のコンポーネントからプログラム変数(シリアライズされたフィールド)を最初に抽出する必要があることを AI に伝える:
    • AI による移植では、元のコンポーネント内のプログラム変数が特殊な方法でシリアライズされていることを理解できない場合があります。
    • AI はまず元のコンポーネントに含まれる特殊なシリアライズデータを抽出し、それを移植後の Cilbox コンポーネントに再適用する必要があります。
    • また、AI はシリアライズされた AnimationCurve フィールドのコピーに苦労する場合があるため、AI には AnimationCurve に特に注意を払わせる必要があります。
    • 上記に失敗した場合、AI がフィールドの値を推測しようとするリスクがあり、これは好ましくありません。
      • これが発生した場合は、修復しようとするよりも、前の時点からやり直すべきです。
  • AI はネットワークや Cilbox の問題の自律的なデバッグにおいて非常に有能である:
    • Claude Sonnet および Claude Opus モデル(2026年10月)を使用したところ、ネットワークテストを含む問題を自律的にデバッグできることがわかりました。
    • これらのモデルは、問題が Cilbox 自体のバグ(または Basis 本体のバグ)によって引き起こされていることを認識し、それを回避することも可能です。
    • プレイモードでテストを実行するよう AI に指示する:
      • AI は Cilbox スクリプトをプレイモードで実行し、Cilbox の動作が意図通りであることを確認する必要があります。
      • Cilbox には、プログラムの構造が健全に見えても実行時にのみ明らかになる未知のバグが含まれていることがあります。
    • 実際のサーバー(ローカルホスト)と 2 つのクライアントでネットワークテストを実行するよう AI に指示する:
      • AI はネットワークの挙動が意図通りであることを自律的にテストし、予期しない状況をデバッグできるはずです。
  • AI エージェントで移植を行う際は、AI が一つずつ取り組めるようにタスクの順序付きリストを作成する:
    • 移植を単一の指示として実行すると、AI が指定されていない順序で操作をインターリーブし、その大部分が不完全または不満足な状態になるリスクがあります。
    • また、AI が移植の状態を把握し続けている限り、タスク間でコンテキストをクリアしても構いません。
    • 以下はタスクリストの要約された例です。一部のタスクはさらなるコンテキストで明確にする必要があり、一部はあなたには当てはまらない場合があります:
  • BIRP シェーダーを使用するマテリアルを URP シェーダーに変換する。
    • (シェーダーの URP への変換、特定の URP シェーダーへの移行、または両方の目に描画されるようにカスタムシェーダーが SPS-I をサポートしていることの確認など、追加の指示が必要になる場合があります)
  • Bakery のライトを Unity のライトに変換し、プレハブに含まれるライトも必ず確認する。
  • ライトマッピングの設定を Bakery から Unity GPU ライトマッパーに変換する。
  • 元のものを複製して、Basis Scene コンポーネントをシーンに追加する。
  • 後で使用するためにすべてのプログラム変数を抽出する。
  • 非エディタースクリプトをすべて、以前に抽出したプログラム変数の助けを借りて、可能な限り忠実に Cilbox と Basis に変換する。
    • GetTrackingData のすべての変換に対してユーティリティ関数を作成し、後で頭と手の回転に対する補正をグローバルに適用して、元のすべての呼び出し元に影響を与えられるようにする。
  • オブジェクトプール(プレイヤーごとにスポーンされるオブジェクトのグループ)を特定し、Cilbox での同等機能を考案する。
  • BIRP マテリアルをターゲットにしていた Animator や Cilbox スクリプトのアニメーションを、URP マテリアルの対応するプロパティをターゲットにするように修正する。
  • シーンオブジェクトとスクリプトを Basis Framework のレイヤーを使用するように変換する。
  • フィールドや Cilbox スクリプトの参照を含め、すべての Unity Constraint を Basis Constraint に変換する。
  • Basis のネットワークモデルを最大限に活用するように Cilbox スクリプトを改善する。
    • メッセージパッシングの変装としてオブジェクトの状態転送を使用していた元のスクリプトの箇所を特定し、直接メッセージパッシングを使用するように変更する。

余談ですが、私の環境では、ネットワーク上に予備の Windows ゲーミングデスクトップマシン(モニターなし)を用意し、 メインマシンの操作を妨げることなく Claude Code が接続して必要なタスクを実行できるようにしました。

これらのタスクの多くは人間の介入なしに数時間かかる場合があるため、その間メインの作業マシンは通常通り稼働させておきたいと思うでしょう。

2026-08-06 Basis で Cilbox を使用したコンテンツ制作

Cilbox は、Basis にデフォルトで用意されているスクリプトシステムです。ワールド、プロップ、アバターなどのユーザー生成コンテンツにはスクリプトを含めることができ、それらは許可された型や関数へのアクセス権限が異なる、個別のサンドボックス内で実行されます。

セットアップ​

クラスは次のように [Cilboxable] としてマークされている必要があります:

[Cilboxable]
public class Pencil : MonoBehaviour {
// ...
  • Cilbox スクリプトを使用するシーンを利用するには、シーン内のどこかに Cilbox Scene Basis コンポーネントが 1 つ存在する必要があります。
    • Cilbox を使用して書かれたシーンスクリプトは、そのコンポーネントの外側に配置できます。
  • Cilbox スクリプトを使用するプロップを利用するには、プロップのルートに Cilbox Prop Basis コンポーネントを 1 つ置く必要があります。
  • Cilbox スクリプトを使用するアバターを利用するには、アバターのルートに Cilbox Avatar Basis コンポーネントを 1 つ置く必要があります。

これらのコンポーネントを個別に設定する必要はありません。

OnEnable が初回に呼ばれない​

Cilbox には、プロップが有効化された初回に OnEnable が呼び出されないという特有の挙動があるようです。

次のような回避策を講じる必要があるかもしれません:

private bool _isEnabled;
public void Start()
{
// ここで一回限りの初期化処理を行う。

WhenEnable();
}
public void OnEnable() { WhenEnable(); }
private void OnDisable() { _isEnabled = false; }
private void WhenEnable()
{
if (_isEnabled) return; // Cilbox の癖
_isEnabled = true;

// ここで OnEnable の処理を行う。
}

ネットワーク​

オブジェクトをネットワーク対応にするには、以下のいずれかを行います:

  • _networkShim = Basis.SafeUtil.MakeNetworkable(this) を呼び出す。
  • または、BasisNetworkShim コンポーネントを追加し、フィールドとして参照する。

オーナーシップの確認には _networkShim.IsLocalOwner() を使用します。

データの送受信:

  • パケットの送信には _networkShim.SendCustomNetworkEvent(bytes, ...) を使用します。
  • パケットの受信には、_networkShim.NetworkMessageReceived += OnNetworkMessageReceived を使用してイベントリスナーを登録します。

プロップはプレイヤーごとに読み込みタイミングが異なるため、パケットの送信を開始するために _networkShim.NetworkReady += OnNetworkReady を使用してネットワークの準備が整ったことを検出する必要があるかもしれません。

Basis Framework の作成者によると、他のプレイヤーがプロップをスポーンさせて所有している本人よりも早く読み込むことは不可能であるため、非オーナー側で OnNetworkReady がトリガーされたときは、ネットワークオーナーがパケットを受信する準備ができていると仮定して安全なはずです。 追記:これが事実かどうかは不明です。

UI ボタン​

公式の Canvas UI ドキュメントに従ってください。

クリック時にイベントをトリガーする UI ボタンを作成するには:

  • Unity UI の Button をフィールドとして参照し、
  • _eraseAllButton.onClick.AddListener(OnEraseAllClicked); のようにイベントリスナーを登録します。

ピックアップ(Pickup)​

ネットワーク同期されたピックアップのセットアップ方法については、公式の Interactable ドキュメントに従ってください。

ピックアップとドロップのイベントを検出するには:

  • BasisPickupInteractable コンポーネントをフィールドとして参照し、
  • pickup.OnInteractStartEvent.AddListener(OnPickup); や pickup.OnInteractEndEvent.AddListener(OnDrop); のようにイベントリスナーを登録します。
  • トリガーイベントには pickup.OnPickupUse.AddListener(OnPickupUse) を使用します。コールバックメソッドには、トリガーの押し始め、ホールド中、離したときを判別できる enum 値が渡されます。

ピックアップはコンポーネントのオーナーシップに影響しません。

  • ユーザーが同期されたピックアップを掴んだ場合、その同期ピックアップコンポーネントのみを所有します。
  • 同じ GameObject 上にある他のネットワークコンポーネントのオーナーシップは変更されません。

テレポーテーション​

シーンプロップは BasisLocalPlayer.Instance.Teleport(spawn.position, spawn.rotation) 関数を呼び出すことでプレイヤーをテレポートさせることができます。

ビルドのトラブルシューティング​

アップロード前のプロップがエディターでは動作するが、ビルドされた BEE ファイルを実際のアプリケーションで読み込むと動作しない場合は、プレイモード中に Unity エディター内で BEE ファイルを読み込み、エディター内で直接デバッグすることを検討してください。

この方法により、アップロード前のプロップがエディターで動作していたのは、Cilbox Prop ボックスコンポーネントが付着していなかったために、プロップ用のコンポーネントではなく Cilbox Scene ボックスコンポーネントを使用して実行されていたからであると判明したことがあります。

サポートされていないもの​

Array.Empty<HVRNPSCilBeacon>() を使用しないでください​

Privilege failed for System.Array.Empty generic argument 0 type HVR.NPS.ForCilbox.HVRNPSCilBeacon

CilboxException: Error: Could not find reference to: [mscorlib][System.Array][HVR.NPS.ForCilbox.HVRNPSCilBeacon[] Empty[HVRNPSCilBeacon]()] Type from:System.Array

ラムダ式を使用しないでください​

ArgumentException: Object of type 'Cilbox.CilboxHeapInstance' cannot be converted to type 'Cilbox.CilboxProxy'.

Vector4 から Vector3 への暗黙のキャストを使用しないでください​

  • 0 個の引数を持つ UnityEngine.Vector4 型のメソッド op_Implicit でエラーが発生しました。
  • AmbiguousMatchException: Ambiguous match found.

バグ

値の反転が正しく動作しない​

危険

最近のバージョンで修正されました。

var x = -someValue; における x の値が不正確になります。

  • Random.Range(-x, x) が期待される範囲を大幅に外れた値を返したときに発見されました。
  • -1 を掛けることで回避できます。

このバグは Cilbox リポジトリで修正されましたが、Basis Framework プロジェクトおよびクライアントに反映されるのは後日になる可能性があります。

サーバーの XML 設定でスケールが無視される​

XML 設定においてプロップのスケールが無視されます。

2026-07-13 なぜ Unity の Export .unitypackage 機能はこれほど多くのアセットを含んでしまうのか?

Unity デフォルトの Export .unitypackage 機能を使用して、プロジェクト間でアセットを転送するためにカスタムアバターをエクスポートする場合、 カスタムアバターではよくあることですが、アセットは複数のフォルダに散らばっていることがよくあります。

しかし、エクスポートの内容には、カスタムアバターには特に必要のない余計なアセットが含まれてしまうことがよくあります。

これらの理由はすべて下の画像にまとめられていますが、最初に挙げる理由は非常に意外なものかもしれません。 最初の理由は詳しく読み、それ以外については、仕組みがわかれば予想がつくものも多いため、ざっと目を通すことをお勧めします。

before.png

プレハブインスタンスがフィールドをオーバーライドしつつ、そのフィールドを含むコンポーネントを削除している​

プレハブインスタンスがコンポーネント上のアセット参照をオーバーライドしており、かつそのプレハブインスタンスがプレハブ内に存在する同じコンポーネントまたは GameObject を削除している場合、 アセットを参照しているオーバーライドは存在し続けます。

さらに、Unity エディターのプレハブオーバーライド UI は正しくなく、削除されたコンポーネント内のオーバーライドされた参照を表示しません。 コンポーネントの削除を取り消すと、そこに含まれていた隠れた参照が露出します。

外部ツールなしでこの問題を検出することはほぼ不可能です。

アクティブなヒエラルキー内の参照が、使用していないプレハブソース内の Transform を指している​

コンポーネントには、アバターでそのプレハブソースを使用していない場合でも、モデルのサブアセットやプレハブ内のトランスフォームへの迷い込んだ参照が含まれていることがあります。

これが問題なのは、そのプレハブソースで使用されているすべての参照と、それらによって参照される他のアセットが必要になってしまうからです。

私のプロジェクトでは、SkinnedMeshRenderer の anchor override がプレハブソース内のトランスフォームを指していることが時々ありました。

参照がプレハブモデルのサブアセットを指している​

プレハブソースやモデルのサブアセット(Mesh など)が必要な場合、プレハブソース全体が必要になります。

これは予想される動作ですが、次の理由により問題になる可能性があります。

プレハブモデル (FBX) に、使用していないテクスチャへの参照が含まれている​

プロジェクト内のテクスチャが、プレハブモデル (FBX) によって直接参照されていることがあります。

アクティブなマテリアルのいずれでもそのテクスチャを使用していない場合でも、含まれてしまう可能性があります。

プレハブソースが、オーバーライドしたアセットを必要としている​

プレハブソースは、プレハブインスタンスまたはプレハブバリアントによってオーバーライドされているため使用していないアセットを、デフォルトで指すようにセットアップされている場合があります。

これは通常、購入したアバターを使用しているが、一部のマテリアルをオーバーライドしている場合に当てはまります。

結果として、これらのアセットが、同様に使用していない可能性のあるテクスチャなどの他のアセットを参照している場合があります。

プレハブソースが、削除したコンポーネントや GameObject 内のアセットを必要としている​

前の理由と非常に似ていますが、アセットを別のアセットでオーバーライドする代わりに、コンポーネントまたは GameObject を完全に削除した場合です。

プレハブインスタンスまたはプレハブソースが EditorOnly の GameObject 内のアセットを必要としている​

コンポーネントや GameObject を削除する代わりの方法として、後で再導入できるようにオブジェクトを保持するために EditorOnly に設定するのが一般的です。

プレハブソースが使用していないアセットを参照している可能性があるだけでなく、自分自身でプレハブインスタンス上のアセットを使用して一部のフィールドをオーバーライドしており、 それが結局使用されないということもあり得ます。

これが未使用アセットとしてカウントされるかどうかは、ワークフローにおいて EditorOnly をどのように使うかは人によって異なるため曖昧ですが、言及しておく価値はあります。

コンポーネントやマテリアルがスクリプトやそのスクリプト内の参照を必要としている​

コードを含む一部のファイルは、エクスポート時に必ずしも必要ではないかもしれませんが、これらのスクリプトが他のオブジェクトを参照していることもあります。

  • マテリアルはシェーダーを必要とします。シェーダーは通常、別途インストールされます。
    • シェーダーには、デフォルトのテクスチャへの参照が含まれていることがあります。それらのテクスチャは通常、シェーダー自体と一緒にインストールされます。
  • GameObject が MonoBehaviour を参照している場合、スクリプトや DLL が必要になることがあります。これらは通常、別途インストールされます。
    • MonoBehaviour がアイコンを使用している場合、そのアイコンが含まれてしまうことがあります。

別のゲームにエクスポートする場合、コンポーネント内の一部の参照が望ましくない場合がある​

あるゲームから別のゲームにエクスポートする場合、コンポーネント内の一部の参照が無関係になることがあります。

  • 一部のコンポーネント(例:Modular Avatar Menu Item)は別のプロジェクトでは無関係な場合があり、アイコンテクスチャなどのアセットへの参照を含んでいます。
  • 一部の独自アセット(例:Expression Menu)は別のプロジェクトで互換性がない場合があり、それらのアセット自体が他のアセットへの参照を含んでいることがあります。
  • 一部の Unity アセット(例:Animator Controller、Animation Clip)は別のプロジェクトでは無関係な場合があります。

ビフォーアフター​

以前の様子(この記事の冒頭にある画像と同じものです):

before.png

その後:

after.png

2025-05-02 Parent Constraint の「Activate」ボタンについて

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

Skinned Mesh Constraint を開発している最中に、奇妙なことに気づいたので、 どこかに記録しておきたいと思いました。

Skinned Mesh Constraint は、メッシュのスキニングによって頂点が動くのと同じように、Parent Constraint を動かすためのツールです。

アプローチ #1:ボーンを対象にした Constraint を Activate する​

私はこれまで、Parent Constraint の Activate ボタンを押すと、GameObject が、レストポーズからの相対的なウェイトの割合に応じて、 ソースの Transform に追従するようになるものだと思っていました。

このため、Skinned Mesh Constraint コンポーネントを作ろうとしたとき、最初のアプローチは、メッシュにペイントされたウェイトを収集し、 対応する Parent Constraint のソースを設定してから、リフレクションを使って(VRC 以外のプロジェクトで)Activate ボタンを押すというものでした。

ところが、ボーンを動かすと、Parent Constraint の動きがメッシュのスキニングと同じになりません(行き過ぎてしまいます)。

アプローチ #2:ボーン内に置いた新しいオフセット用 Transform を対象にした Constraint を Activate する​

次に、レスト時に Position Constraint と同じ位置になるようオフセットした新しい Transform を、ボーンの内側に作ってみました。

この方法はうまくいきました。つまり、Parent Constraint が内部でどう動作するのかについて、私は何か勘違いしていたのです。これらの観察結果をもとに、 2 つの Discord サーバーで助けを求めました。

すると Sacred さんという方から、私のアプローチ #2 がメッシュのスキニングに対する正しいアプローチだと教えてもらいました(Sacred さんはこのアプローチを Voxian さんという別の方の功績としていたので、 私は再発見しただけです)。また、複数の Constraint(ボーン 1 つにつき Parent Constraint 1 つ)を使う、それより劣る別の方法もあるとのことでした。

この方法には Constraint の数が少なくて済むという利点がありますが、Transform の参照がより多く必要になります。

この Discord でのやり取りをきっかけに、3 つ目のアプローチの調査を始めました。

アプローチ #3:オフセットの Transform を手動で計算する​

Activate ボタンをクリックすると、Parent Constraint は、レスト時の Constraint ソースに対する相対オフセットの情報を保存します。

アプローチ #1 では、これらのオフセットに何らかの値が入っています。

アプローチ #2 では、これらのオフセットはすべてゼロです。

この新しいアプローチでは、先ほど作った、ボーン内のオフセット用 Transform の位置と回転を、これらのオフセットにコピーしてみました。 Constraint は、それらのオフセット用 Transform ではなく、ボーンを直接対象にしています。

結果として、これらのオフセットを手で書き込むことで問題が解決しました。追加の Transform を参照する必要もないため、 これが Skinned Mesh Constraint で採用している方法です。

オフセットは、回転については (Quaternion.inverse(sourceTransform.rotation) * constraint.rotation).eulerAngles を使って計算し、 平行移動のオフセットについては sourceTransform.InverseTransformPoint(constraint.position) こちらの回答を参考にしています。

では、Constraint の Activate ボタンは何をしているのか?​

Constraint の Activate ボタンがどう動作するのか、私にはわかりません。

このボタンが何をしているのかを可視化してみましたが、奇妙です。Activate をクリックすると、脚のボーンのオフセットが、予想外の位置を指しています。

Unity_lThgeTtkJV.png

上の画像では、赤い線の先端がオフセットのワールド空間での位置を指していますが、Constraint の対象となっているオブジェクトはレスト状態です。

メッシュのスキニングに従うように Parent Constraint を設定した場合、レスト位置そのものがオフセットなので、赤い線はまったく表示されないはずです。

これ以上のことは私にもわかりませんが、 これは、Constraint の Activate ボタンを押した場合の挙動が、 オフセットを手で計算した場合とは異なることを意味します。

2025-05-03 更新: Activate ボタンの仕組みについて新しい情報を知ることができたので、 この記事は、製品のアップデートとともに、後ほど更新する予定です。

2025-02-07 マテリアルごとの衝突エフェクトと、壊せるオブジェクト

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

1 年以上使い続けてきたホームワールドに飽きてしまい、新しいホームワールドを作っていました。

最初は、壁を撃ったときに、当たったマテリアルに応じて、デカールと適切な効果音が出るようにしたいだけでした。きっかけは、Gabe Newell が出演している Half-Life 1 のドキュメンタリーで、楽しさ、補強(フィードバック)、そしてワールドのインタラクティブ性について語られていたことです(2 分間の視聴、15:08〜17:03)。 Unity エンジンの中で、こんな基本的なゲーム機能の実装に今まで時間をかけてこなかったのは、おかしな話だと思いました。

ところが、構想が手に負えないほど膨らみ、最終的には、投げられて壊れるオブジェクトの類似品を作ることになってしまいました。

以下は、このシステムを作る中で学んだことを、順不同でまとめたものです。 一部の項目は

💬 VRChat
プラットフォーム固有で、他のソーシャル VR アプリや 他のゲームには当てはまらない場合があります。

重要な衝突には Continuous Speculative を使わない​

リジッドボディが投げられて別のコライダーに当たったとき、衝突の力を使って、適切な効果音の音量とダメージ量を適用する必要があります。 衝突イベントは、この情報を含む OnCollisionEnter を通じて発生します。

問題は、リジッドボディの衝突判定のタイプによっては、壁に強く投げつけられたオブジェクトでも、報告される量が頻繁に 0 ニュートンになってしまうことです。 これは特に、リジッドボディが Continuous Speculative に設定されている場合に起こります。

対処法は、衝突によって正しい力が報告され、音が鳴ったりダメージを受けたりしてほしいオブジェクトについては、衝突判定のタイプを Continuous Dynamic にすることです。 たとえば、壊れるオブジェクトから飛び散る個々の破片には、これは必要ありません。

💬 VRChat
あるツイートによると、VRCObjectSync を持つオブジェクトは 「リジッドボディを強制的に Continuous Speculative にする」 そうなので、 Udon スクリプトを通じて、衝突判定のタイプを強制的に Continuous Dynamic にする必要があるかもしれません。

壊れたオブジェクトからは跳ね返らない​

ボウリングの球を壊れやすい窓に投げつけたら、ボウリングの球はエネルギーの大半を保持しているので、窓を突き抜けるはずです。

動かないオブジェクトとの衝突では、たとえその動かないオブジェクトが当たった瞬間に壊れても、投げたオブジェクトはそこから跳ね返ってしまいます。 これは、動かせる重い壊れるオブジェクトにも当てはまります。

私が選んだ対処法は、投げられたオブジェクトの速度と角速度を、FixedUpdate で物理フレームごとに保存しておくことです。 オブジェクトが壊れるとき、衝突した相手のオブジェクトにそれを知らせ、跳ね返りを打ち消して、最後に保存した物理フレームの速度と角速度に基づき、 エネルギーの大半を再び与えます。

複数マテリアルのメッシュで、当たったマテリアルを取得する​

環境メッシュ/ワールドメッシュが複数のマテリアルでできている場合、弾丸や衝突が当たったときには、 そのマテリアルが表すもの(たとえばガラスなど)に応じて、異なる音が出るべきです。

この情報は、コライダーの Physics Material のタイプにエンコードできますが、別のアプローチもあります。

メッシュが複数のマテリアルを持っているとき、Mesh Collider とレイキャストを使って、当たったマテリアルを取得することは可能です。 このアプローチを使うべきかどうかは、また別の問題です。

そのためには、レイキャストが Mesh Collider に当たると、三角形のインデックスが報告されることを利用します。

三角形のインデックスをマテリアルに変換するには、その Mesh Collider のメッシュアセットを取得し、そのメッシュの各サブメッシュについて、 triangleCount[subMeshIndex] = mesh.GetSubMesh(subMeshIndex).indexCount / 3; を使って、そのサブメッシュの三角形の数を求めます(3 で割っている点に注意してください)。

メッシュに 3 つのサブメッシュがあり、その三角形の数が順に 70, 50, 40 だとすると、三角形のインデックスが 74 なら、インデックス 1(2 番目)のサブメッシュに当たったことを意味するので、 インデックス 1 のマテリアルスロットを取得できます。三角形のインデックスが 122 なら、70 + 50 以上なので、インデックス 2 になります。

💬 VRChat
Udon ではサブメッシュのデータにアクセスできないため、このデータは Edit モードで好きな方法でベイクしておく必要があります。

繰り返しになりますが、メッシュを分割するか、マテリアルごとに別々のコライダーを使うほうが、現実的かもしれません。

リモートプレイヤーで、オブジェクトが早く壊れすぎないようにする​

💬 VRChat

プレイヤープールを使ったパケットの送受信(Player Objects)​

オブジェクトがダメージを受けたとき、この情報は、VRCObjectSync の階層の外にある Manual Sync の情報を通じて届きます。

私のワールドでは、ダメージのネットワーク処理を、多数のプレイヤープール(現在は Player Objects と呼ばれ、ここでは永続化しないもの)を使って設計しました。

  • 各プレイヤーは、自分専用の Manual Sync オブジェクトのセットを持ちます。送る必要がある専用の*「パケット」*ごとに 1 つずつです。
  • オブジェクトにダメージを与えるときは、プレイヤーは、前回のシリアライズ以降にそのプレイヤーが与えたダメージの個々の事例をすべて含むパケットを送信します。 そのパケットには、シーン内の多数のダメージを受けるオブジェクトのうち、どれに当たったかという情報が含まれます。
  • 全員がそのパケットを受け取り、対応するオブジェクトにダメージを適用します。
  • デフォルトのネットワークオーナー(マスターとも呼ばれます)が、それらのオブジェクトの実際の体力を追跡し、他の全員がオブジェクトの実際の体力について同じ認識を持つようにします。

送信されるいくつかのパケットのうちの 1 つの例。

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class DamageableSyncMessage : UdonSharpBehaviour
{
[HaiInjectable] [SerializeField] private DamageManager manager;

[UdonSynced] public int[] bundle_entityId;
[UdonSynced] public int[] bundle_healthReduction;

private readonly List<DamageableSyncMessageBundle> _queued = new List<DamageableSyncMessageBundle>();

// [...]

public override void OnPreSerialization()
{
var queuedMessageCount = _queued.Count;
bundle_entityId = new int[queuedMessageCount];
bundle_healthReduction = new int[queuedMessageCount];

for (var index = 0; index < queuedMessageCount; index++)
{
var bundle = _queued[index];
bundle_entityId[index] = bundle.entityId;
bundle_healthReduction[index] = bundle.healthReduction;
}

_queued.Clear();
}

public override void OnDeserialization()
{
// [...]
manager._InternalReceiveDamageableSyncMessage(entityId, healthReduction);
// [...]
}

// [...]
}
internal class DamageableSyncMessageBundle { /* ... */ }

各プレイヤーは、それらのオブジェクトの体力を、ローカルで個別に追跡します。 これには次の利点があります。

  • オーナー権の移譲を完全に避けられ、複数のプレイヤーが同じオブジェクトに同時にダメージを与えられる。
  • ダメージを与えた人と、ダメージが与えられたことを確認するデフォルトのネットワークオーナーとの間の往復も避けられる。
  • ローカルプレイヤーがオブジェクトを壊す行動をとった場合、ネットワークからの応答を一切待たずに、即座に壊れる。

その結果、メッセージを受け取るとすぐにオブジェクトが壊れ、ローカルプレイヤーがオブジェクトを壊した場合は、そのオブジェクト自体のオーナーでなくても、ローカルで遅延なく壊れます。 正確さや、ダメージの帰属の適切さよりも、視覚効果を重視した、非常に反応の良いシステムになっています。

ダメージのパケットが早く届きすぎて、ダメージの視覚的な原因が起こる前に届いてしまう​

これは、弾丸によるダメージについてはうまく機能します。

しかし、壁との衝突によるダメージは、常にリジッドボディ自身のネットワークオーナーから送信されます。 ところが、この衝突によるダメージのパケットは、オブジェクトが壁に当たる前の時点で他のプレイヤーに届くことがよくあります。

その結果、オブジェクトが空中で壊れたり、さらにひどい場合は、投げられる前に壊れたりします。オブジェクトが壊れるという結果と、 その原因との間にあるこの視覚的な断絶は、見ていて違和感があります。

これを解決するために私が選んだ方法は、壁との衝突専用の、2 つ目の専用ダメージパケットを作ることです。リジッドボディのネットワークオーナーが、 リジッドボディが何かに衝突したときの位置を含む、その専用のダメージパケットを送信します。

衝突で壊れる演出は、意図的に遅らせる​

他のクライアントでは、その専用のダメージパケットを受け取ったことで体力が 0 以外の値から 0 になる場合、壊れる視覚効果は、次のいずれかが起こるまで 意図的に遅延されます。

  • リジッドボディの位置が、その専用パケットに示された位置に到達する、または
  • そのパケットを受け取ってから最大 1 秒の制限時間に達する、または
  • オブジェクトが、衝突以外の何らかの理由(弾丸が当たったなど)でダメージを受ける。

このアプローチには例外的なケースがあります。たとえば、オブジェクトが衝突によって壊れるものの、それがそのオブジェクト自身が投げられた結果ではなく、 別のオブジェクトが投げ込まれた結果である場合です。これは、まだ修正が必要かもしれません。 2025-02-09 追記:このケースは、 ダメージを与えた側のネットワーク遅延を推定することで対処しました(たとえば、バレーボールがガラスに投げられてガラスが壊れた場合、ダメージを与えたバレーボールの 物理オブジェクトのオーナーのネットワーク遅延が必要です)。その遅延を使って、オブジェクトを後から壊すようにしています。

さらに、このアプローチはデシリアライズの順序に非常に敏感で、これは欠点です。特に、デフォルトのネットワークオーナーは、 オブジェクトの実際の体力についての更新を送信し、体力について食い違いがあった場合に、他のプレイヤーが再同期できるようにします。

デフォルトのネットワークオーナーが、体力が 0 以外から 0 になったと宣言する特殊なケースがあります。 この場合、ダメージパケットによってまだ壊れていなければ、オブジェクトは即座に壊れます。デフォルトのネットワークオーナーがリジッドボディのオーナーでもある場合は問題になります。 ダメージパケットが、体力の変化の確認と同時に受信されることが多いからです。

この特定の問題は、現時点では適切な方法で解決できていません。この場合は、デシリアライズ処理のコードの一部を Update ループに移して、 ダメージパケットが先に処理されるようにしましたが、この応急処置は長続きしません。

備考

Udon の Continuous 同期を使う、別の VRCObjectSync の状態を悪用する、 あるいは VRCObjectSync の代替品や独自実装を使うといった、他のアプローチはまだ試していません。

壊れたオブジェクトを無効にすると、そのネットワーク処理が途切れる​

💬 VRChat

オブジェクトが壊れたとき、VRCObjectSync を含む GameObject を無効にしてはいけません。無効にすると、リジッドボディの 状態の更新が他のクライアントに届かず、空中で固まってしまうことがあります。このため、レンダラーとコライダーを無効にし、リジッドボディのオーナー側では、 壊れた状態の間、リジッドボディの位置、回転、速度、角速度を強制的に一定の値にしています。

このオブジェクトは、リジッドボディのオーナー側で、見えない状態で前もって遅延をかけてリスポーンさせ、その後すべてのクライアントで再び有効にします。 こうすることで、オーナー以外のクライアントがリジッドボディの位置を補間して、他のオブジェクトと意図しない衝突を起こすことを防げます。

オーナー権の移譲を避ける​

💬 VRChat

プレイヤーが動かせるオブジェクトを撃ったら、そのオブジェクトは少し押しのけられるべきです。

2 人以上のプレイヤーが同じオブジェクトを同時に撃つとき、オーナー権の移譲が頻発するのは避けたいところです。

このため、プレイヤーがオブジェクトを撃ったときは、前のセクションで説明したのと同じように、Player Objects を使って専用のパケットを送信します。 このパケットには、弾丸がどこから撃たれたか、その弾丸の方向、そして、そのオブジェクトのローカル空間のどこに弾丸が当たったかが記述されています。

このパケットをリジッドボディのオーナーが受け取ると、その弾丸の力をオブジェクトに再び適用し、効果音とデカールも一緒に出します。 これにより、オーナー権の移譲なしに、両方のプレイヤーからオブジェクトが少し押しのけられるようになります。ただし、撃った側から見ると、目に見える遅延があります。

持ち上げている間の衝突音​

💬 VRChat

オブジェクトが持ち上げられているときでも、そのオブジェクトが壁にぶつかったときには、音が出るべきです。

これは、オブジェクトを持ち上げたユーザーのもとでは正しく動作しますが、オブジェクトが持ち上げられている間、リモートのユーザーのもとでは 衝突イベントが発生しないようです。

これを改善するために、オブジェクトが持ち上げられている間だけ、オーナーが、他のクライアントで再生されるネットワーク効果音を、限られた数だけ送信します。

ルックアップテーブル​

このプロジェクトを進める中で、Edit モードでベイクしたルックアップテーブルがいくつも必要になりました。

  • 弾丸がコライダーに当たったとき、デカールは、そのコライダーの見た目の表現に貼り付く必要があります。それが動くオブジェクトであっても同様です。
    • 動くオブジェクトが、必ずしもオブジェクト同期されているとは限りません。たとえば、ドアのロジックはネットワークを使っていますが、ドアのリジッドボディ自体はネットワークオブジェクトではありません。 そのため、ネットワーク同期されていないオブジェクトの一部にも、それを識別する方法が必要です。
  • オブジェクトがダメージを受ける可能性がある場合、ダメージパケットを送信するために、そのオブジェクトを識別する必要があります。そうすれば、ダメージ効果をすべてのクライアントに適用し直せます。
  • そこで、識別子が必要になりそうなすべてのオブジェクトに数値の識別子を関連付け、一種の並行するネットワーク ID を作りました。 1 つの Behaviour がそれらの識別子をすべて集中管理するので、日常的にコピーされるプレハブやオブジェクトが、この情報を持ち歩く必要はありません。
  • それらの識別子は、Edit モードのスクリプトで更新されます。これは、Edit モードで依存性注入を行うスクリプトと同じものです。
  • 何らかの理由でコライダーに当たったときには効果音を出す必要があるため、各コライダーには、その Physics Material、またはレンダラーのマテリアルに基づく衝突音の識別子が関連付けられています。サブメッシュがある場合は複数になるため、三角形インデックスのルックアップテーブルもその一部です。

手の速度を取得する​

Half-Life: Alyx のグローブに似た操作感を再現するために、手を払う動作を検出するには、手の速度を取得する必要があります。 ただし、アナログスティックによる移動を、手を払う動作と誤って解釈されたくはありません。

毎フレーム、

💬 VRChat
Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Origin) の位置と回転を使って、トラッキング空間の原点の TRS 行列を作り、 その空間に対する相対位置として手の位置を記録します。

次に、現在のフレームで、前のフレームの手の相対位置を、現在のフレームのトラッキング空間の原点の行列に再び適用します。 これにより、ワールド空間での 2 つの位置が得られ、それを使ってワールド空間での手の速度を求められます。

ワールド空間であれば、内積を使って、特定の方向(たとえば頭の向きと反対の方向や、特定のオブジェクトから遠ざかる方向)への手を払う動作を検出できます。

2024-12-08 他のアプリケーションでModular Avatarを動かす方法

警告

このページは主に2024年12月8日に作成されました。以下の情報はすぐに陳腐化する可能性があります。
このページはChatGPTを使用して翻訳されています。翻訳に誤りが生じる可能性があります。

こんにちは。他のアプリケーション(VRChat以外)でModular Avatarを動かそうとする場合に得た知見を共有します。

Modular Avatar V1.xはほぼ他アプリと互換性あり​

Modular Avatar V1.xは、VRChat以外のアプリでもほぼ互換性がありますが、一部のVRChat特有の機能は削除されます。

これは、プロジェクト内でVRChat SDKが検出されない場合、Modular AvatarではVRChat特有のコードが既に無効化されるためです。

例えば、MA Merge Armatureは汎用的で動作しますが、MA Menu ItemはVRChat特有であるため動作しません。

以下のセクションは、互換性を確保するために必要な作業について説明しています。

Modular Avatarは一部のリリースでコンパイルエラーが発生することがある​

Modular Avatarは公式にはVRChatプラットフォームのみをサポートしているため、一部のリリースではVRChatなしのコードパスが見落とされ、コンパイルエラーが発生することがあります。

この問題を解決するには、#if MA_VRCSDK3_AVATARSおよび#endifを適切な場所に追加し、VRChat特有のインポート、フィールド宣言、またはコードブロック全体をコンパイラが無視するようにする必要があります。

何かがcontext.AvatarDescriptorを必要とする場合、その関数のほとんどの内容をコンパイルから削除できると仮定できます。

このプルリクエストを例として参照してください。

ALCOMやVCCを使用している場合、Modular AvatarをインストールするとVRChat SDKもインストールされる​

VCCやALCOMを通じてModular Avatar V1.xをインストールすると、プロジェクトにVRChat SDKもインストールされます。 これは望ましくありません。VRChat以外のプロジェクトにVRChat SDKを含めてはいけません。VRChat SDKはプロジェクト設定やビルドプロセス(レイヤー名、XRプロジェクト設定など)を汚染し、ターゲットアプリの機能に干渉します。

ALCOMパッケージマネージャーを使用している場合、次の手順を実行できます:

  • Unityプロジェクトを閉じる
  • Modular Avatarをインストールする
  • VRChat SDK Avatarsパッケージをアンインストールする
  • VRChat SDK Baseパッケージをアンインストールする

VCCを使用している場合、一度インストールされたVRChat SDKパッケージをアンインストールすることはできません。非VRChatプロジェクトではALCOMに切り替えるか、ソースから手動でModular Avatarをインストールしてください。また、NDMFをインストールする必要があります。

ALCOMもVCCも使用していない場合、ソースから直接インストールできます。この場合もNDMFをインストールする必要があります。

将来的には、Modular Avatar V2.xをインストールしてもVRChat SDKがプロジェクトにインストールされなくなると聞いています。

Modular AvatarはDLLに依存する​

Modular Avatarはプロジェクト内にDLLを必要としますが、これらは通常VRChat SDKによって提供されます。

これらのDLLはChilloutVRやBasisなどの他のプロジェクトには欠けていることが多いです。

ChillaxinsをALCOMを通じてインストールするか、Microsoft NuGetから以下のDLLを手動でインストールしてください(System.Collections.Immutable、Lib.Harmony)。

必要なDLL:

  • System.Collections.Immutable DLL(NDMFで使用)
  • Lib.Harmony DLL(NDMF 1.5で使用)

Modular AvatarはUnity Burstパッケージに依存する​

Modular Avatarはプロジェクト内にUnity Burstパッケージを必要とします。これは通常VRChat SDKによって自動的に含まれます。

ChillaxinsをALCOMを通じてインストールするか、UnityのパッケージマネージャーでBurstをプロジェクトに追加してください。

NDMFをビルドプロセスに接続する​

Modular Avatarがアバターをビルド中に動作するようにするには、NDMFをビルドプロセスに接続する必要があります。

ChilloutVRアプリケーションでは、Chillaxins内でこれを実装しました(ALCOMを通じてインストール)。このコードを参照してください。

Basisフレームワークでは、このコードを参照してください。これはBasisのソースコード自体にすでに含まれています。

2024-04-07 OSC トラッカーを使った移動アニメーション(振り返り)

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

VRChat には OSC トラッカーという機能があります。これは、カスタムの SteamVR ドライバーを作らなくても、ソフトウェアで制御するトラッカーを追加できる低コストな方法として存在し、Quest 単体のヘッドセットでも使えます。

実験として、移動の「アニメーション」にこの OSC トラッカーを使ってみたいと思っていました。移動アニメーションのロジックを、Animator Controller とアニメーションクリップではなく、外部のプログラムから与えるのです。

不思議なことに、書き留めたのはつい最近ですが、これをやりたいとはもう 1 年以上前から思っていたと確信しています。

フラットスクリーンの三人称視点ゲーム​

三人称視点のゲームを遊んだことがあれば、これから話す内容はおわかりでしょう。グランド・セフト・オート、アサシン クリード、アンチャーテッド、メタルギアソリッドなど、どれも三人称視点で主人公を操作します。一人称視点の場合でも、動き回る NPC がいます。ゲームキャラクターの動きを良く見せることに特化した産業が、丸ごと存在しているのです。

アイデアは、外部のプログラムで、三人称視点ゲーム用のキャラクターコントローラーを作る(または再利用する)ことです。そのキャラクターに OSC トラッカーを持たせ、私の生身の体の代わりに、この仮想キャラクターが VRChat のアバターを操るようにします。

このテーマについては、興味深い講演がいくつもあります。私のお気に入りの講演とデモをいくつか紹介します。

https://www.youtube.com/watch?v=KSTn3ePDt50

  • Kristjan Zadziuk (Ubisoft) - GDC 2016 - Motion Matching, The Future of Games Animation

https://www.youtube.com/watch?v=7S-_vuoKgR4

  • Michal Mach (Naughty Dog) - GDC 2017 - Physics Animation in Uncharted 4: A Thief's End

https://www.youtube.com/watch?v=RCu-NzH4zrs

  • Joe van den Heuvel (Valve) - SIGGRAPH 2021 - Character Locomotion in Half-Life: Alyx

https://www.youtube.com/watch?v=16CHDQK4W5k

  • Ubisoft La Forge - SIGGRAPH 2020 - Learned Motion Matching

一般に、VR 以外の既存分野には確立された技術がたくさんありますが、それらが VR でどう通用するのかは本当に気になるところで、私はまだほんの表面をなぞっているにすぎません。

このプロジェクトは、ソーシャル VR の文脈で、ゲームアニメーションのどの技術を再利用できるかを探り始めるための手段でもあります。

VR には独自の制約があり、何が許容できるかには限界があります。その制約を見つける良い方法は、意図的に、あるいは偶然に、それを破ってみることだと思います。

JLPM22 による Unity 向けモーションマッチング​

GDC 2016 で、Ubisoft の Kristjan Zadziuk 氏が、彼らが考案した手法「モーションマッチング」(上の最初の動画を参照)を発表しました。

この手法はゲーム業界に波紋を広げました。移動サイクル用のアニメーションの断片を使う代わりに、特別に収録してラベル付けした連続的なモーションキャプチャのセッションから直接サンプリングして、キャラクターの移動を直接駆動できるようになったからです。

実際、キャラクターは方向転換や停止の際に適切に体重移動で補正するので、ワールド内での非常に洗練されたキャラクターの動きが実現します。

JLPM22(Jose Luis Ponton)というハンドルネームのユーザーが、この手法のオープンなバージョンを Unity で実装して公開しています。

https://github.com/JLPM22/MotionMatching

これは実験を始めるのに良い出発点になりそうです。

プロジェクトをインポートし、手持ちのソーシャル VR 用アバターで動くようにいくつか修正を加えました(A、B)。

アイドルポーズが少し変でした(キャラクターが前かがみになっています)が、おおむねうまく動いていることに気づきました。

それ以外は、キャラクターは期待どおりにアニメーションし、平らな面の上しか動きませんが、テストにはそれで十分でしょう。

後で問題になりそうな点がすぐにいくつか予想できますが、試すのをやめるほどではありません。

  • キャラクターが止まるのにも動き出すのにも時間がかかる。
  • 移動中に頭が上下に揺れる。
  • キャラクターが向きを変えるのに時間がかかる。
  • キャラクターがストレイフ(横移動)できない。

私にはモーションキャプチャスタジオがなく、部屋も狭すぎて、長い距離をまっすぐ歩く本格的な自作モーションキャプチャはできません。そのため、既存のモーショントラッキングデータを自分のデータで補うことはできず、その制約の範囲内で動くようにするだけです。

OSC トラッカー​

次に、そのキャラクターに OSC トラッカーを追加します。

過去の Double Hip Tracker のようなプロジェクトで、OSC トラッカーのデータを VRChat に送るための小さなライブラリをすでに書いていたので、これは簡単に済みました。

少し気をつけるべきなのは、OSC トラッカーのデータが相対的であることです。外部プログラムの中ではキャラクターがワールド空間を移動しますが、私自身は物理的な空間を移動しません。そのため、OSC トラッカーのデータは頭を基準にした相対値で送る必要があります。

これを解決する方法はいくつかあり、そのひとつは頭のデータを VRChat に送ることだったかもしれません。しかし私は代わりに、OSC トラッカーの位置を、キャラクターの頭の X 位置と Z 位置を基準にした相対位置として再計算し、それを HMD の X 位置と Z 位置にずらすことにしました。Y 位置(高さ)は無視します。キャラクターの頭は上下に動きますが、私の物理的な頭は同じ高さのままだからです。キャラクターが上下に揺れるのに合わせて、足が地面にめり込んでほしくありません。

Unity シーン内で、プレイスペースに対する HMD の位置が必要です。その情報を得る方法も何通りかありますが、過去のプロジェクトで Unity を使った SteamVR のオーバーレイ(およびバックグラウンドアプリ)をすでに作っており、HMD の位置を取得する方法も含まれています。オーバーレイなので、VRChat が VR で動作している間も実行し続けられます。

外部プログラム内でキャラクターを動かすために、このイテレーションでは次のようにしました。

  • 動き回るのには、引き続きサムスティックを使っている。
  • アバターパラメーターの Velocity を OSC 経由で取得し、その情報をキャラクターコントローラーに送ることにした。
    • 次のイテレーションでは、後述するトラックパッドを使うように切り替えました。
  • キャラクターの向きは、HMD の回転(HMD の前方向)で制御する。
    • いくつか欠点があります(たとえば真下を向くと体が反転しますが、これは、ワールドの下方向との内積を使い、そのうえで HMD の上方向にフォールバックすれば簡単に解決できます)が、テストには十分です。

結果は次のとおりです。

先ほど挙げた問題のいくつかが、はっきりと見えています。

VRChat 側のキャラクターの速度が、外部プログラム内のキャラクターの動きと一致していません。その理由は次のとおりです。

  • 外部プログラム内のキャラクターは現実により即して接地しているため、動きの反応が鈍い(これはモーションキャプチャのパフォーマンスの要件に左右されるかもしれません)。
  • ワールド内の速度は、普通の人間のキャラクターにしては速すぎ、常に全力疾走しているようなものである(しかも、ワールドごとに異なります)。

その結果、ゲーム内の移動が非常にキビキビしているため、足が地面の上で目に見えて滑ってしまいます。

移動の快適さ?​

VRChat は、快適さのために、加速のないキャラクターコントローラーを採用しており、それは理にかなっています。すべての動きは、サムスティックに直接対応した直線的な動きで、スムージングは一切ありません。

あらゆる年齢層を対象にしたゲームでは、ユーザーの快適さを保つことが重要になりがちです。ただ、近年では、ほぼ生まれたときから VR に慣れていて極端な移動方式にも耐えられる、新しい世代の若いユーザー層を特に狙い始めている VR 開発者もいます。

私には、今でも耐えられない VR 体験があります。特に次のようなものです。

  • 目に見えるケージなしで、加速しながらキャラクターを横方向に回転させるゲーム。
  • 床と天井の区別がつかない、地面に立つタイプの体験(たとえば、床と天井の両方が動画プレイヤーになっている、一部のソーシャル VR の音楽ヴェニューなど)。

それでもこれを試してみたいと思い、2 つ目のイテレーションに進みました。

外部プログラム内のキャラクターがサムスティックになる​

このイテレーションでは、もうサムスティックを使ってゲーム内を移動しません。代わりに、外部プログラム内のキャラクターがサムスティックになります。

コントローラーのトラックパッドを使って、外部プログラム内のキャラクターに動き回るよう指示します。VRChat 内の Velocity はもう使いません。

外部プログラム内のキャラクターが実際にワールド内を動き回ると、その動きが OSC 入力(/input/Vertical と /input/Horizontal)で VRChat に送られます。

トラックパッドの値は OpenVR の API を使って読み取っています。サムスティックは、VRChat のサムスティックによる移動と競合してしまうため使えません。OSC 入力は、小さな値を送っているときにコントローラーの入力を乗っ取ってくれないようなのです。

サムスティックの動きは HMD の向きを基準にした相対的なものなので、外部プログラム内のキャラクターの動きも、HMD の向きを基準にした相対的なものに変換する必要があります。

結果は次のとおりです。

この結果ははるかに良く、特に急停止のときなど、足の動きがある程度接地して見えます。ただし、気づく点がいくつかあります。

  • 足はまだ滑りますが、以前ほどではありません。これは、VRChat のアバターの動きと、外部プログラム内のキャラクターの動きが一致していないことを示しています。
  • HMD を素早く回転させても、キャラクターの上半身が素早く回転することにはなりません。頭の向きを強調するには、モーションキャプチャデータとのより良い統合が必要かもしれません。
  • 頭は HMD の位置で駆動されているため、上下に揺れることができません。
  • 静止した VR の腕が、キャラクターの動きと対照的で、非常にぎこちなく見えます。
  • 外部プログラム内のキャラクターの動きは、高低差の変化に対応できません。また、モーションキャプチャデータにもおそらくその情報はありません。

これが外から見た様子です。

内側から見ると、キャラクターの移動はもはや線形ではありませんが、滑らかな加速があるわけでもなく、その中間のようなものです。

また、トラックパッドの入力とゲーム内の動きの間には断絶があります。入力と、実際に VR の視界が動くことの間に遅延が生じるからです。これは本当に不安定な感覚ですが、不思議なことに、それによる VR 酔いはあまり感じません。

その他の考察​

ほかにもいくつか問題が残っています。物理的にプレイスペース内を動いても足は動きませんが、キャラクターに動くよう指示すると、必要がなかったのにワールド内で移動する入力を VRChat に送ってしまいます。この誤ったフィードバックはおそらく解決できますが、このプルーフ・オブ・コンセプトで自分に課した目標はすでに達成できています。

それ以外は楽しそうに見えますが、フルボディトラッキングに対応させるのはあまりにも手間がかかるため、おそらくこのプルーフ・オブ・コンセプトに再び手を付けることはないでしょう。

私の結論:このプロジェクトの新しいイテレーションを作るなら、一人称視点のキャラクターと三人称視点のキャラクターで異なるアニメーションを使い、移動中にネットワーク同期されるキャラクターのアニメーションが他の人から見て良く見えるようにしつつ、一人称視点のキャラクターは引き続き快適に動き回れる、そんなソーシャル VR アプリを作れないかを調べてみたいと思います。

  • VR では、一人称視点の移動は、入力に対して直接的で線形なままにします。
  • 鏡に映る一人称視点のキャラクターの移動は、一人称視点の体験と一貫した、反応の良いものにすべきです。
  • 一方、一人称視点ではないローカルカメラに映るキャラクターの移動は、キャラクターがサムスティックで移動しているときには、三人称視点の移動アニメーションシステムで駆動してもよいでしょう。そうすれば、録画した映像で見栄えが良くなります。
  • 他のユーザーから見たローカルキャラクター(=ネットワーク同期されたもの)も、そのローカルキャラクターがサムスティックで移動しているときには、三人称視点の移動アニメーションシステムで駆動してもよいでしょう。
  • これは、ワールド内でのキャラクターの位置がゲームプレイにとって重要な、対戦要素のあるソーシャル VR ゲームの文脈には当てはまりません。

目標は、一人称視点での違和感を生むことなく、ワールド内でのキャラクターの移動を面白く見せることです。

ソーシャル VR に特化した新しいモーションキャプチャデータ(動きがもっとキビキビしていて、上半身の動きを強調したものなど)を収録してみたり、より洗練された統合を試したりして、改善につながるかを確かめてみるのも良さそうです。

上記の概要。プルーフ・オブ・コンセプトなので、そのコードには最適化されておらず堅牢でもない粗い部分があります(その一部は、VR の最中に問題を修正するためにコードをホットパッチしながら書いたものです)。

2023-12-22 Recalculate Normals の振り返り

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

選択したブレンドシェイプの法線を再計算することで、SkinnedMeshRenderer のシェーディングを改善するツールを作りました。

これを作る中で学んだことを共有します。

ドキュメント:

Recalculate Normals | Haï~
Improves the shading of SkinnedMeshRenderers by recalculating select blendshape normals (and tangents).

このツールは MIT ライセンスで公開しています。ライセンスの条件の範囲で、自由に使ってください!

https://github.com/hai-vr/prefabulous-avatar/blob/main/Packages/dev.hai-vr.resilience.prefabulous/Scripts/VRC/Editor/PrefabulousHaiRecalculateNormalsPlugin.cs

法線とは?​

簡単に言うと:

  • 床の上に、床に対して垂直な棒を立てます。
  • 床が傾いていれば、垂直な棒も傾きます。
  • メッシュのすべての点に、このルールに従って垂直な棒を立てます。

この垂直な棒が法線です。その点が最も多くの光を受ける方向を割り出すために使われる情報です。太陽が棒の真上にあれば、その頂点は最大の光を受けます。

この情報は、反射(棒の真上からその点を見ると、自分自身が映り込みます)にも使われますし、シェーダーのほかの多くの処理にも使われます。

VRChat における、ブレンドシェイプの法線​

ブレンドシェイプは、メッシュの点を動かすことでメッシュを変形させます。

動くのは点だけではなく、法線も動かす必要があります。点が動いたのですから、その下の面の新しい傾きに合わせて、垂直な棒をどう傾けるかという新しい情報が必要になるのです。

ところが、VRChat の文脈では(正確には Unity の側であり、VRChat に非はありません)、問題があります。

Unity には、次のような問題がありました。

  • 顔のブレンドシェイプを変更すると、ブレンドシェイプ自体の差分法線によってメッシュの法線が汚染され、不正に再計算されるため、アバター全体のシェーディングが変形してしまうことがある。
  • トゥーンシェーディングのアバターでは、顔の法線を再計算するのは間違いであることが多い。アバターのアーティストは、顔の曲面から通常計算されるものとは異なるように、顔の法線を描き直しているからです。
  • 上記の癖の副作用として、Unity の不正な再計算により、VRAM 上でも、圧縮されたダウンロードバンドルのサイズでも、メッシュのサイズが大幅に膨れ上がる。

このため、VRChat のユーザーには、メッシュの設定を「Legacy Blendshape Normals」にするか、ブレンドシェイプの法線をインポートも再計算もしない設定にすることが推奨されがちです。

この推奨の結果として、ブレンドシェイプに差分法線がなくなります。ブレンドシェイプには、その棒をどう傾けるかを記述するデータがなくなるため、シェーディングが不正確に見えます。

体の一部を平らにしたり、盛り上げたり、大きくしたりすると、面の傾きを記述する情報は元の面のままになるため、見た目がおかしくなります。

残念ながら、これより悪い選択肢しかないので、この推奨が最善です。

本来であれば、トゥーンの顔の表情用ブレンドシェイプや、技術的なブレンドシェイプ(体の一部を隠すものなど)でない限り、ブレンドシェイプの法線はそれに応じて再計算されるべきでしょう。

さらに詳しく知りたい場合は、こちらをご覧ください。

Unityでカスタム法線メッシュにブレンドシェイプを適用した際に法線が崩れる問題の解決法 - Qiita
はじめにUTS2.0などのNPRシェーダを使ってキャラクターを可愛く見せようとした時、顔メッシュの法線編集は必須になってくると思います。しかし、Blenderなどでカスタム法線メッシュを作成し、…

OpenKnowledge/Normals, Tangents, BlendShapeNormals のUnityインポート設定の解説
この記事では、Unity のFBXインポート設定にある「Normals」等の解説を行います。 実際の画面では、以下の場所にある設定項目を主に解説します。Unity のバージョンは 2018.4.20f1 です。 # 先に結論を VRChat向けアバターとしては `Normals = Import, Tangents = Calculate Mikktspace, Blend Sh...

スクリプトによるブレンドシェイプの法線の再計算​

Unity には、スタティックメッシュの法線を再計算する関数があります。

これを使って、再計算すべき各ブレンドシェイプの差分法線を再計算します。

Unity - Scripting API: Mesh.RecalculateNormals
Thank you for helping us improve the quality of Unity Documentation. Although we cannot accept all submissions, we do read each suggested change from our users and will make updates where applicable.

試している途中で、Unity の RecalculateNormals のドキュメントにも記載されている問題に遭遇しました。

インポートされたメッシュは、すべての頂点を共有しているとは限りません。たとえば、UV シームにある頂点は 2 つの頂点に分割されるため、RecalculateNormals 関数は UV シームで滑らかでない法線を作成します。

(Unity のドキュメントからの引用を翻訳したものです。)

これは実用上の問題であることがわかりました。アーティストが体の各部分を別々の UV シームに分割している場合、アバターのアーティストの作業フローでは 1 つの滑らかにシェーディングされた頂点であっても、ゲームエンジン向けのメッシュデータ表現では、UV データが異なるため、そのアーティストが作った 1 つの頂点が複数の頂点に分割されます。

UV データは、各頂点がテクスチャのどこからサンプリングするかを決めるために使われます。ゲームエンジンのレンダラーの観点では、アーティストから見れば 1 つの頂点であっても、シェーディングには複数の頂点が使われることを意味します。

この技術的な事情が、RecalculateNormals 関数の出力に問題を引き起こします。再計算によって、アーティストが作った同じ 1 つの頂点に対して、異なる角度の垂直な棒が複数立てられてしまうのです。

その結果、法線データに UV シームが見えるようになります。これはシェーディングに悪影響を与えます。光、反射、リムが突然途切れてしまい、法線を変更せずに放置するよりも悪いと言ってよいでしょう。

右側:何の対処もせずに Unity の RecalculateNormals 関数を実行した例。法線に目に見える境界が生じていることに注目してください。これは元のアバターの UV シームの変化と一致しており、元のアバターではメッシュの局所的な領域でテクスチャの解像度を上げるために使われています。

これに対処するため、次の戦略を採りました。

  • 法線を再計算する前に、位置と法線が同じ点のすべての組のインデックスを、データ構造に保存します。
    • これらの点は、実質的に UV シームです。
    • タンジェントは UV データに基づいており、異なる可能性があるため、タンジェントが等しいかどうかは確認しません。
  • 次に、RecalculateNormals を使って法線を再計算します。
  • 法線を再計算した後、そのデータ構造を再利用して、それらのインデックスの法線だけを、平均して正規化することで再々計算します。
  • その後、RecalculateTangents を使ってタンジェントを再計算します。

完璧ではありませんが、良いトレードオフです。ソースコードはこちらをご覧ください。特に ReRecalculateNormalsInUVSeams メソッドとその呼び出し元に注目してください。

全体的な戦略​

全体的な戦略は次のとおりです。

  • ベイク用の SkinnedMeshRenderer を作成し、すべてのブレンドシェイプのウェイトを 0 に設定します。
  • BakeMesh を使って、そのスキンドメッシュからスタティックな Mesh を作ります。
  • 上で説明した対処を使って、法線とタンジェントを再計算します。
    • ブレンドシェイプが 1 つも有効でない場合でも、法線とタンジェントを再計算します。差分法線と差分タンジェントは、実際の法線ではなく、Unity が法線であるべきと判断したものに基づいて計算したいからです。
  • このメッシュが、再計算済みのベースメッシュになります。
  • 続いて、再計算すべき各ブレンドシェイプについて、個別に(すべてのブレンドシェイプをまとめて再計算するわけではありません):
    • ブレンドシェイプのウェイトを最大に設定します (厳密には、元のブレンドシェイプの各フレームの各ウェイトについてこれを行います)。
    • BakeMesh を使って、そのスキンドメッシュから別のスタティックな Mesh を作ります。
    • 上で説明した対処を使って、法線とタンジェントを再計算します。
    • この個別のブレンドシェイプのメッシュの法線と、再計算済みのベースメッシュの法線との差分を保存します。
    • この個別のブレンドシェイプのメッシュのタンジェントと、再計算済みのベースメッシュのタンジェントとの差分を保存します。
    • これらが、後でメッシュに書き戻す必要がある差分法線と差分タンジェントです。
  • これらがすべて終わったら、変更したブレンドシェイプを(元のメッシュのコピーに)書き戻します。
    • Unity の API には単一のブレンドシェイプだけを編集する方法がないため、すべてのブレンドシェイプをクリアして、すべてを書き直すことで回避する必要があります。
    • 差分法線と差分タンジェントを保存しておくのはそのためで、書き直しの操作を何度もではなく 1 回で済ませられます。

その後に計算される差分は Unity の判断だけに基づいているため、Unity が法線は変わらないと判断すれば、差分はゼロのままです。

差分は、アーティストの判断と Unity の判断を比較して計算してはいけません。そうすると、ほぼすべての頂点で両者の意見が食い違い、アバター全体のシェーディングが変わってしまいます。 ブレンドシェイプが 1 つも有効でないベースメッシュでも法線を再計算することで、これを明確に避けています。

動かない頂点も再計算される​

頂点の位置がブレンドシェイプによって変化しない場合でも、すべての頂点の法線とタンジェントが再計算されます。頂点の位置が変わると、その頂点とスムージングされている隣接頂点もすべて法線が変わるからです。その隣接頂点自体の位置が変わらない場合でも同様です。

再計算を動く頂点だけに限定すると、特に体の一部を平らにするブレンドシェイプで、奇妙なシェーディングの「オーラ」効果が目立つことが、実験でわかりました。そのため、この制限をなくして、シェーディングが一貫するようにするほうが良いのです。

隣接する頂点が影響を受けるため、VRAM の使用量がわずかに増えるはずですが、RecalculateNormals の安定性に頼って、影響を受けないすべての頂点の差分法線と差分タンジェントが 0 になることを期待しています。

最後に​

追加するブレンドシェイプを提案する UI も加えました。顔の表情用のブレンドシェイプはおそらく再計算すべきではなく、衝突防止用のブレンドシェイプも同様なので、これはユーザーが選択する仕組みになっています。

ともあれ、このツールは MIT ライセンスで公開しています。ライセンスの条件の範囲で、自由に使ってください! VRCFury などにネイティブに組み込んでもらえるかもしれませんね。

ドキュメント:

Recalculate Normals | Haï~
Improves the shading of SkinnedMeshRenderers by recalculating select blendshape normals (and tangents).

https://github.com/hai-vr/prefabulous-avatar/blob/main/Packages/dev.hai-vr.resilience.prefabulous/Scripts/VRC/Editor/PrefabulousHaiRecalculateNormalsPlugin.cs

2023-12-10 Udon MIDI をデータバスとして使う試み(振り返り)

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

私は、MIDI を使って外部プロセスから Udon と通信しようと試みた、数少ないクリエイターの一人です。

試してみて学んだことを共有します。

なぜ MIDI なのか?​

外部プロセスから Udon にデータを送る方法は、(網羅的ではありませんが)それほど多くありません。

  • ストリーミング動画プレイヤーと GPU リードバック
  • String Loader のポーリング
  • 入力システム(ジョイスティックのデータ、VR の入力など…)
  • MIDI

ここで注目している MIDI はリアルタイム用途に向いています。ここでいうリアルタイムとは、遅延が数ミリ秒未満のアプリケーションを指します。

たとえば、現実世界でトラッキングしている物体の位置を、ワールドと同期させるのに使えます。

残念ながら、対応する OSC のドキュメントにはワールドへの言及があるものの、OSC は利用できません。

VRChat のコンポーネント​

Udon は VRCMidiListener コンポーネントを通じて MIDI データを受信できます。このコンポーネントは、探すデバイス名を指定する起動パラメーターが定義されていない限り、最初に見つかった MIDI デバイスを監視します。すべてのイベントは UdonBehaviour に送られます。

念のため補足すると、MIDI イベントはネットワーク同期されません。そのため、すべての処理は、MIDI データを送受信するローカルマシン上で行われます。

仮想 MIDI デバイス​

私は MIDI や音楽の経験がなく、外部プロセスから Udon へデータを送る手段の一つとして MIDI に関心があるだけです。

VRChat が MIDI メッセージを受信するには、MIDI イベントを書き込めるデバイスを作る必要があります。これは仮想 MIDI デバイスを使えば実現できます。macOS ではネイティブにサポートされているようですが、Windows には仮想 MIDI デバイスのネイティブサポートがないため、サードパーティ製のドライバーをインストールする必要があります。

インターネットを探し回りましたが、真に無料でオープンソースの解決策は見つかりませんでした。そのため、利用に一部制限のある、プロプライエタリまたはソース公開型の無料ダウンロードソフトウェアを使うことになります。いくつかはここに挙げられています:https://support.troikatronix.com/support/solutions/articles/13000081815-using-virtual-midi-ports

検索結果にはよく LoopBe1 や LoopMIDI が出てきますが、私個人は sbvmidi を使い、問題なく動きました。結果は環境によって異なる場合があります。

  • これらのソフトウェアは、入力用の MIDI デバイスと出力用の MIDI デバイスを作成します。
  • イベントは、仮想の出力 MIDI デバイスに送ります。
  • イベントは、仮想の入力 MIDI デバイスから再び出力されます。
  • VRChat が監視するのは、その仮想の入力 MIDI デバイスです。

送信側のライブラリ​

私は個人的に DryWetMIDI .NET ライブラリを使いました。これにより、VRChat SDK を使わない Unity プロジェクトからも MIDI データを送信できます。

データ型​

Udon の MIDI は 3 種類のイベントをサポートしています。

  • NoteOn
  • NoteOff
  • ControlChange

これら 3 種類のイベントには、それぞれ次のものがあります。

  • チャンネルを表す 4 ビットの数値
  • ノートを表す 7 ビットの数値
  • ベロシティまたは値を表す 7 ビットの数値

文字列のエンコード​

外部アプリから Udon に送るデータの中身は、データバッファサイズの制約(この記事の後半を参照)に合わせて、用途ごとに特別に作り込んだものにできます。ただ、処理もデバッグも簡単なので、まずは文字列から始めましょう。

問題は、バイト列を文字列にデコードするための Encoding.UTF-8 を Udon で使う簡単な方法が、工夫をしても見つからなかったことです。

そのため、文字列を UTF-8 のバイト列にエンコードする代わりに、文字列の各 Char(1 文字あたり 2 バイト)を処理することにしました。

こうすれば、文字列に ASCII 以外の文字が含まれていても、簡単に復元できます。

1 つのノートで 2 バイトを送る​

どのイベントタイプも合計 18 ビットあるので、1 つのイベントの中に Char(16 ビット)を 1 つエンコードすることも可能なはずです。

しかし、私は代わりに次のようにしました。

  • 各 Char の下位 7 ビットを、それぞれ 2 つの 7 ビット数値に格納する
  • 1 つ目の Char の最上位ビットが 0 なら、NoteOn イベントを使う
  • 1 つ目の Char の最上位ビットが 1 なら、ControlChange イベントを使う
  • 2 つ目の Char の最上位ビットが 0 なら、チャンネル 14 を使う
  • 2 つ目の Char の最上位ビットが 1 なら、チャンネル 15 を使う

これにより、エンコーダーが使うチャンネルは 2 つだけに限られます。

問題:VRCMidiListener/VRCPortMidiInput にバグがある​

内部クラス VRCPortMidiInput にある VRCMidiListener の問題さえなければ、すべてうまく動いていたはずです。

前回のフレームを処理してから受信した MIDI イベントが多すぎると、VRChat は例外を発生させます。MIDI イベントが多すぎると VRChat 内部でバッファの過剰読み取りが起こり、イベントを正しく処理できなくなります。これは、前回のフレームからおよそ 128 イベント程度でも起こりえます。

VRChat がその最大数の 8 倍を超えるイベントを処理しなければならない場合、状況によってはクライアントがクラッシュする可能性もあります。影響を受けるのは MIDI イベントを送信しているマシンだけで、MIDI イベントはそもそもネットワーク同期されないため、インスタンス内の他のプレイヤーには影響しません。

したがって、MIDI を使って VRChat に安全にデータを送るには、呼び出し側で VRChat 自体の安定性を確保する必要があります。

  • VRChat が 1 フレームで処理できる数を超えるイベントを送らないようにする。
    • これは、イベントキューまたはメッセージキューを使えば実現できます。
    • VRChat が 1 フレームで処理できる量より大きなメッセージについても、イベントをキューに入れて処理できるようにしてください。
  • 追加のイベントを送る前に、VRChat がフレームの処理を終えたことを検出する。
    • これは、FileSystemWatcher を使って VRChat の出力ログを監視することで実現できます。
    • MIDI イベントを処理する Udon の挙動の中で、そのフレームに少なくとも 1 つのノートを処理した場合、そのフレームのすべてのノートの処理が終わった後(LateUpdate を使用)に、送信側が検出できる 1 行をログに出力します。
  • VRChat が応答しなくなったときや、ユーザーがワールドから退出したときを検出する、またはタイムアウトを処理する。
    • 送信側はまずフレームの処理が完了したことを検出しなければならず、VRChat が応答していないときはそれが決して起こらないため、キューが大きくなりすぎるのを防げます。
  • VRChat が送信側より遅れている場合は、送信するデータをキューに溜めないようにする。
    • VRChat が処理し終えるころには意味を失っているメッセージで、キューが膨れ上がるのは避けたいところです。

以上です​

VRChat のフレームレートが低いほど、VRChat が受信データを処理する速度は遅くなる可能性があります。

本当の解決策は、ワールド向けの OSC ですが、現実的に考えて、作業を進める必要があるときにそれを待っていられるでしょうか?

2023-09-01 アバターが制御不能に回転しないように TeleportTo を連続して使う方法

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

TeleportTo を連続して使い、独自の移動システムを作ろうとすると、プレイヤーが制御不能に回転してしまうことがあります。

これは何年も前から口伝えで知られてきた知識ですが、私が調べた限り、完全に説明している記事はオンライン上に見つかりませんでした。 TeleportTo (U#) の AlignRoomWithSpawnPoint (U#)(U# 以外のドキュメントには記載がありません)と、 TrackingDataType.Origin (U#)(ドキュメント)がどう連携するのかは、 それぞれの説明を見ただけで自力で理解することを求められているかのようです。

というのも、Networking.LocalPlayer.GetRotation() を渡すという直感的な方法で関数を呼び出しても、 テレポート後のプレイヤーの回転が、指定した回転と等しくなる保証はないからです。

プレイヤーを同じ回転のままテレポートさせるには、次の 2 つが必要です。

  • プレイヤー自身ではなくプレイヤーのルームの中心をテレポートさせる、VRC_SceneDescriptor.SpawnOrientation.AlignRoomWithSpawnPoint を引数に取るバージョンの TeleportTo 関数を使うこと。
  • Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Origin) を使って、ワールド内における現在のプレイヤーのルームの中心を求めること。

次の関数をそのままプロジェクトで使用できます。

/*
This code snippet is released into the public domain.
If the public domain is not applicable where you are, then it's released under the terms of
the CC0 1.0 Universal, which is essentially the same as public domain.
*/

// Teleporting to self should always result in the player not moving
StableTeleportTo(Networking.LocalPlayer.GetPosition());

// Teleport to other position
public Vector3 destinationPos = ...;
StableTeleportTo(destinationPos);

private static void StableTeleportTo(Vector3 destinationPos)
{
var playerPos = Networking.LocalPlayer.GetPosition();
var playerOrigin = Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Origin);
var posToOrigin = playerOrigin.position - playerPos;
var destinationOrigin = destinationPos + posToOrigin;

Networking.LocalPlayer.TeleportTo(
destinationOrigin,
playerOrigin.rotation,
VRC_SceneDescriptor.SpawnOrientation.AlignRoomWithSpawnPoint
);
}

この関数は連続的なテレポートのために設計されているため、回転に関するパラメータは受け取りません。

プレイヤーが目的地に位置するようにルームの中心の位置を計算し、その位置にプレイヤーのルームの中心をテレポートさせます。

2023-05-07 ボンディング・インタラクション

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

ResilienceVR は、既存のプラットフォームの枠にとらわれずに実験と試行錯誤を重ねるために、私が開発しているソーシャル VR アプリです。

この記事では、最初の実験がどのように動作するかを説明します。レイテンシがあっても、アバターの IK がオブジェクトや他のプレイヤーに反応できるようにする仕組みです。

既存のアプリケーションにおけるネットワーク IK​

ソーシャル VR では、ユーザー同士が 1 つの仮想空間でつながっており、たいてい、自分自身を表す 1 つの仮想的な姿(アバター)を操り人形のように操作します。

現在、ソーシャル VR アプリケーションがプレイヤーの仮想的な姿を他のプレイヤーに送る主な方法は 2 つあります。

  • アバターの腰の位置とボーンの回転を送る、または
  • アバターの重要なボーン(頭や手など)、あるいはハードウェアトラッカーの位置と回転を送る。

これらの方法にはそれぞれ欠点があります。後者の方法では、エンドユーザー側が他のプレイヤーのポーズを推定するために、追加の計算をする必要があります。

いずれにせよ、どちらの方法でも、ネットワークを通じて送るデータ量が大きくなります。そのため、アプリケーションのニーズに応じて規模を拡大するには、さらなる簡略化と最適化が必要です。

一般に、全員のアバターが仮想空間内で互いに近い位置にいる最悪のケースでは、こうしたアプリケーションのネットワーク使用量はプレイヤー数の 2 乗で見積もれます。新しく参加したプレイヤーのデータを、インスタンス内にすでにいる他のすべてのプレイヤーに送る必要があるからです。

最適化をしない場合:

  • プレイヤー数が 2 倍になると
    10 人のインスタンスは、5 人のインスタンスよりもネットワークへの負荷が 4 倍になります。
  • プレイヤー数が 10 倍になると
    80 人のインスタンスは、8 人のインスタンスよりもネットワークへの負荷が 100 倍になります。

このスケーリングの問題は、多くの最適化手法の主な動機になっています。静的でないデータをネットワークで送ることには、深刻な金銭的コストがかかりうるからです。

レイテンシの問題​

ユーザー同士は依然としてレイテンシによって隔てられており、場合によってはプレイヤー間の遅延が 200 ミリ秒から、まる 1 秒にも及ぶことがあります。

こうした遅延は、プレイヤーが他のプレイヤーの行動に反応して自分のアバターを動かすことを妨げます。手をつなぐ、一緒に踊る、あるいは別のプレイヤーも操作している仮想空間内のオブジェクトを操作しようとすると、レイテンシのために見た目にずれが生じます。

ボンディング・インタラクション​

この問題の部分的な解決策として、ResilienceVR では、すべてではないものの、プレイヤー同士の一定程度の反応的なインタラクションを可能にするアプローチを実験しています。ソーシャル VR にはさまざまな文脈がありますが、私が特に関心を持っているのは**ユーザー同士の絆(ボンディング)**です。お互いの体に手を置くことで、手をつないだり、一緒に踊ったりできる手段を提供します。

ここでの目的のために、VR の外では「ビクティム・コントロール」として知られているものに注目します。これは、演技の分野で舞台上の格闘(ステージコンバット)に使われる用語です。格闘シーンを演じるとき、加害者役の俳優は、実際には被害者役を制御していません。被害者役は自由に動く権利を完全に保っています。つまり、ある俳優が被害者役の俳優の頭をつかむ演技をしても、被害者役の頭は、加害者の意志ではなく、被害者自身の意志で自由に動き回れるということです。

私たちのケースでは、誰かの手足を制御しているのは、インタラクションを始めた人ではありません。

文脈は重要なので、この実験では、プレイヤー同士が対立する他のソーシャル VR のインタラクション、たとえば素手で戦う格闘ゲームやガンゲームは対象にしていません。

戦闘に特化したネットワーク処理には、さらなる課題があるかもしれません。たとえば、この文脈では戦闘中のプレイヤー同士が対立しているため、あるプレイヤーが他のプレイヤーの手足の制御を奪えることが必要になるかもしれません。他の誰かがあなたの肘を引き離したら、あなたの手はどうなるでしょうか?「ネットワーク版 Boneworks」は、まったく異なる問題を解くことになるでしょう。これは、ResilienceVR での今後の実験のテーマになるかもしれません。

プレイヤー以外とのインタラクション​

このシステムは主にプレイヤー同士のボンディングのために設計されましたが、たまたま、仮想空間内のアイテムやオブジェクト、さらには他のプレイヤーが持っているアイテムとのインタラクションも、簡単に拡張して実現できることがわかりました。

Boneworks や Half-Life: Alyx のようなシングルプレイヤーのゲームに大きく影響を受け、プレイヤーの手がオブジェクトに引き寄せられるように機能を拡張し、その引き寄せを他のプレイヤーにもレイテンシなしで見えるようにしました。

これらのシングルプレイヤーゲームとは違い、リジッドボディのシミュレーションやコリジョンは関与しないため、初歩的な形のインタラクションであることには注意が必要です。

仕組み​

簡単に言うと、ローカルプレイヤーの手が、何かに引き寄せられているかどうかを他のプレイヤーに伝えます。つかんだ瞬間には、それをどこでつかんだかを他のプレイヤーに伝えます。データを継続的に送り続けるわけではありません。

その後、各プレイヤーが、それぞれのプレイヤーの手がそれぞれのオブジェクトにどれだけ強く引き寄せられているかを計算し、部分的な IK ソルバーを実行して、ネットワーク IK による手の配置とオブジェクトによる手の配置の中間のどこかに手を置きます。引き寄せが強いほど、オブジェクトに近くなります。

詳細:

ステップ 1:ローカルユーザーが、ネットワーク IK 用にプレイヤーのポーズを保存する​

通常の IK ソルバーが実行され、解かれたポーズが、ネットワーク IK の一部として送信できるように保存されます。ネットワーク IK のポーズは、以下のステップで説明するシステム全体が存在しないかのような構成で、常にローカルのデータとともに送信されます。

すべてのユーザーのアバターは、ネットワーク IK に基づいてポーズが取られます。

ステップ 2:ローカルユーザーが、最も魅力的なオブジェクトを見つける​

ローカルでは、プレイヤーの手が(すでにオブジェクトをつかんでいない場合)、自分の周りにある、引き寄せられる可能性のあるオブジェクトを探します。

ここでの目的では、プレイヤーの手足は引き寄せられる対象のオブジェクトです。

利用可能なオブジェクトが見つかると、現在の手の配置を前提に、そのオブジェクトに完全に引き寄せられた場合に、手がオブジェクト上のどこに置かれるかを計算します。

手の配置の候補が計算できたら、それぞれの候補について磁化量を計算します。これは、現在の手の配置が、手の配置の候補にどれだけ近いかを示すスカラーの推定値です。また、オブジェクトによっては、他のものより魅力的なものがあります。

磁化量が最大のオブジェクトが勝者となります。

  • ユーザーが何かに引き寄せられている場合、ユーザーはどのオブジェクトに引き寄せられているかを他のユーザーに送ります。オブジェクトは、引き寄せられる対象を識別するための数値で表されます。
  • ユーザーがそのオブジェクトをつかむと決めた場合、ユーザーはそのオブジェクトに対する手の相対的な位置と回転を送ります。これは、最初につかんだ後は更新されません (例外が 1 つあり、後で説明します)。
  • ユーザーが何にも引き寄せられていない場合、ユーザーは、何にも引き寄せられていないという情報を送ります。

この方法の利点は、低コストなことです。イベント駆動です。引き寄せの状態は常に変わるわけではなく、送信されるデータも最小限です。フェーズが変わるとき以外は、ネットワークデータの補間はほとんど必要ありません。

この計算はローカルで行われるため、他のプレイヤーについて、引き寄せられるオブジェクトの探索を実行する必要はありません。そのため、引き寄せ状態への遷移が遅れることがあります。

磁化量は、ユーザー入力を駆動するために再利用できます。磁化量が一定のしきい値を超えたら、ユーザーがそのオブジェクトとインタラクションできるようにするのです。たとえばクライミングに便利です。

磁化量は他のプレイヤーには送信されないことに注意してください。遅延をよりよく考慮に入れるため、磁化量は各プレイヤーのもとで再計算されます。

ステップ 3:すべてのユーザーが、引き寄せられているユーザーについて部分的な IK を実行する​

ユーザーは、自分自身も含め、該当するすべてのユーザーについて、IK をローカルで解きます。

IK ソルバーは、次の場合、そのプレイヤーについてローカルでは実行されません。

  • そのプレイヤーが見えていないとき、または
  • そのプレイヤーが何にも引き寄せられていないとき、または
  • そのプレイヤーが、無効になっているオブジェクトに引き寄せられているとき、または
  • そのプレイヤーが、非表示になっている誰かのアバター上にあるオブジェクトに引き寄せられているとき(たとえば、カリングされているために Animator が更新されていない場合)

こうすることで、必要のないユーザーを繰り返し処理せずに済みます。

ResilienceVR は独自に書いた IK ソルバーを使っているので、IK のどの部分を、どのように実行するかをより細かく制御できます。そのため、現在ポーズを取っているアバターに基づいて、腕だけを解くことができます。

ユーザーは、その特定のオブジェクトに対する手の配置を計算し、続いて磁化量を計算します。

この磁化量を使って、ネットワーク経由の手の配置と最終的な手の配置の間を補間します。これにより、手は、リモートではなくローカルでオブジェクトに近づくにつれて、そのオブジェクトに引き寄せられていきます。

反復の方法は 2 つあります。

  • ソルバーの反復をすべて、ユーザーごとに別々に、すべてのプレイヤーで同じ順序で実行する、または
  • 各ユーザーでソルバーの反復を 1 回ずつ実行し、全ユーザーにポーズを適用してから、再び反復する。

どちらにも欠点があります。各ユーザーがお互いの手をつかもうとするシナリオや、お互いの肘をつかもうとするシナリオでは、安定性の問題につながる可能性があり、これはまだ実験が必要です。

相対的な動き(ステップ 2 内)​

つかんでいる手を動かしながらも、ネットワーク IK の恩恵を受けられるようにしたいと思っています。

プレイヤーがこの動作を始めると決めたとき、次の内容を含む別のネットワークイベントを送ります。

  • この動作を始めた瞬間の手の位置と回転、および
  • この動作を始めた瞬間の、持っているオブジェクトの位置と回転。

これはその後更新されないため、補間はありません。ソルバーのフェーズでは、プレイヤーの手をオブジェクトに対する相対的な差分として扱います。これにより、このステップで自前の補間を行わなくても、あらゆるネットワーク IK の滑らかさの恩恵を受けられます。

プレイヤーがこの動作をやめると決めたときは、新しくベイクした位置と回転を含む新しいグラブ動作を送ります。

VRChat での実装​

「ResilienceVR IK Demo」という VRChat の Udon ワールドは、上記のシステムを、VRChat プラットフォームの許される制限の範囲内に収めて移植したものです。

現在の Udon は実行速度の遅さが制約になりますが、この成果を披露するには十分です。ResilienceVR からのコードの変更を最小限に抑えたかったため、これは Udon 固有の最適化をほとんど行っていない素朴な移植であることに注意してください。

それでも、簡略化のために IK ソルバーの一部を無効にする必要がありました。もしこれがネイティブな Udon プロジェクトであれば、ソルバーをもっと効率的に書いたでしょう。

VRChat はアバターのメッシュへのアクセスも、ボーンへの書き込みアクセスも提供していません。そこで、ワールド自体にアバターのモデルを SkinnedMeshRenderer として含め、そのアーマチュアを動かすことで、ユーザーがアバターを着ているように見せています。

ワールド内のアバターペデスタルが、ボーンの回転と長さが正しいこと、そして表示されているときに Animator が動作していることを保証します。表示されるメッシュがないために Animator が動作していない場合、Udon の VRCPlayerApi は更新されたボーンの回転を返しません。

この VRChat ワールドは、次のように動作します。

ステップ 1:ローカルユーザーが、ネットワーク IK 用にプレイヤーのポーズを保存する​

すべてのプレイヤーのボーンの位置と回転(VRCPlayerApi を使用)を収集し、メモリにコピーします。これらの位置と回転は、IK を解く過程で何度も読み書きされます。

ResilienceVR とは異なり、ネットワーク IK を担当するのは VRChat なので、ワールドから、送信されるボーンに今後影響を与える手立てがない以上、これについて心配する必要はありません。

ステップ 2:ローカルユーザーが、最も魅力的なオブジェクトを見つける​

この部分は ResilienceVR とほぼ同じですが、CyanPlayerObjectPool の助けを借りて、Udon の手動同期でイベントを送信します。元のイベント駆動の性質のおかげで、Udon への移植はとても簡単です。

ステップ 3:すべてのユーザーが、引き寄せられているユーザーについて部分的な IK を実行する​

Udon はプレイヤーのデータへのアクセスが不十分なため、ほとんどの最適化を実現できません。

この場合、次のものに限定しました。

ユーザーは、自分自身も含め、該当するすべてのユーザーについて、IK をローカルで解きます。

IK ソルバーは、次の場合、そのプレイヤーについてローカルでは実行されません。

  • そのプレイヤーが正しいアバターを着ていないとき
  • そのプレイヤーが何にも引き寄せられていないとき

プレイヤーが正しいアバターを着ているかどうかは、ヒップ、スパイン、チェスト、ネックそれぞれの間の距離を測ることで検出できます。プレイヤーが同じモデルのオリジナルアバターを着ている場合は、誤検出が起こりえます。

プレイヤーは個別に処理され、ポーズはただちに適用されます。実行順序が結果に影響するため、多少のずれが生じることがあります。

解かれたポーズは、ワールド内にある SkinnedMeshRenderer のアーマチュアのボーンに適用されます。

結果​

この実験の結果、自分で体験できる VRChat のワールドが生まれました。私の作品が、既存のソーシャル VR ユーザーに、お気に入りのソーシャル VR プラットフォームにもっと多くを望み、求める気持ちを抱かせるきっかけになればと思っています。

ResilienceVR は、共有したい新しいアイデアを試行錯誤するために使える、ソーシャル VR アプリであり続けます。

プレイヤー同士のインタラクションは、ユーザー体験の面で興味深い課題をたくさん生み出すため、研究すべきことはまだたくさんあります。特に、つかむインタラクションと磁化の強さについては、多くのフィードバックをいただいており、今後取り組んでいくつもりです。

https://twitter.com/vr_hai/status/1653570784999120899

Patreon​

Patreon、Pixiv Fanbox で支援してくださっているすべての皆さん、そして Booth で私の無料ツールにチップを贈ってくださった皆さん、ありがとうございます!

私の作品や研究を気に入っていただけたら、Patreon での支援をご検討ください!

Haï~ | creating Virtual reality gimmicks and productivity tools | Patreon
Become a patron of Haï~ today: Get access to exclusive content and experiences on the world’s largest membership platform for artists and creators.

2023-01-27 QvPen2Pen の振り返り

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

友達が VR の中から、私の 3D プリンターを使って絵を描けるようにしました。

始まり​

3D プリンターを使い始めて数か月が経ち、何とかして VRChat のワールドとつなげて、友達がプリンターに取り付けたペンを操作できないかと考え始めました。

ただ、それまでは、スライサーソフトが出力した gcode ファイルを SD カードに入れ、それをプリンターに直接挿して印刷するだけでした。

最大限の効果を出すために、描いた絵が、できるだけ低いレイテンシでリアルタイムに現れるようにしたいと考えました。

Twitter で、基本的なアイデアを添えて質問してみました。

https://twitter.com/vr_hai/status/1618541782899519489

すぐに、Prusa MK3S+ プリンターの USB-B ポートを使ったシリアルポート通信で可能だと教えてもらいました。ありがたいことに、3D プリンターはメインのPCのすぐそばにあるので、さっそく接続して試してみました。

先行事例​

gcode をリアルタイムに送る方法についてアドバイスを求めるツイートをしたところ、先行事例をいくつか教えてもらえて、とてもありがたかったです! これはプロッターを使ったものです。

getting an autograph from a vtuber!?? 😱
VShojo #ENVtuber Vshojo:Twitter ⯈ https://twitter.com/VShojoOfficialInstagram ⯈ https://www.instagram.com/vshojo/Website ⯈ https://www.vshojo.com/VShojo tal...

計画​

ペンで描いた線(自分や他のプレイヤーが描いたもの)をプリンター上に実体化するために、QvPen(ペンのプレハブ)を改造して、Udon を使ってペンの線の位置をゲームのログに出力するようにします。そのうえで、ログパーサーを書き、ペンの位置を gcode の命令としてシリアルポート経由でプリンターに継続的に送信します。

Debug.Log でペンの位置/クリック/リリースを出力 → ログパーサー → gcode に変換 → シリアルポート

プリンターの様子はワールドにライブ配信で戻すので、効果を高めるために、これはできるだけ低いレイテンシで行う必要があります。

シリアルポート通信と gcode の学習​

ざっと検索したあと、デバイスマネージャーを見て、3D プリンターの通信ポート(COM ポート)を確認しました。

次に、プリンターのシリアルポート設定(ボーレートなど)を調べたところ、それに言及している OctoPrint のログにたどり着きました。

_port = new SerialPort(portName, 115200, Parity.None, 8, StopBits.One);
_port.Open();

それから、基本的な gcode の命令を調べました。プリントヘッドを X 軸と Y 軸(プリントベッドの軸)、そして Z 軸(上下)に動かす方法と、移動速度を制御する方法です。主に使う gcode は G1 です。

private const int InternalFastestMillimetersPerSecond = 200;
private const int FastestMillimetersPerMinute = InternalFastestMillimetersPerSecond * 60;

// Editor's note: SafeWriteLine and other functions are described in the next section

public void AsFastAsPossible()
{
SafeWriteLine($"G1 F{FastestMillimetersPerMinute}");
}

public void MoveTo(float xMillimeters, float yMillimeters)
{
if (IsFloatIllegalRange(xMillimeters) || IsFloatIllegalRange(yMillimeters))
{
throw new ArgumentOutOfRangeException($"{xMillimeters} {yMillimeters}");
}
SafeWriteLine($"G1 X{xMillimeters:0.000} Y{yMillimeters:0.000}");
}

public void PressPen(float amount01)
{
if (amount01 < 0 || amount01 > 1)
{
throw new ArgumentOutOfRangeException();
}

if (amount01 < 0.001f)
{
MoveZToAndIgnoreLess(Retracted);
}
else
{
MoveZToAndIgnoreLess(FullyPressed * amount01 + BarelyPressed * (1 - amount01));
}
}

public void ReleasePen()
{
MoveZToAndIgnoreLess(Retracted);
}

public void MoveZToAndIgnoreLess(float zMillimeters)
{
if (IsFloatIllegalRange(zMillimeters))
{
throw new ArgumentOutOfRangeException();
}

if (zMillimeters < 9f)
{
return;
}
SafeWriteLine($"G1 Z{zMillimeters:0.000}");
}

// Editor's note: SafeWriteLine and other functions are described in the next section

失敗​

学習の過程で、プリンターが物理的に誤作動するような失敗をいくつかしました。誤作動は次のような形で現れました。

  • Z 軸(プリントヘッドが下がる動き)で、ベッドが動いている最中にノズルがベッドに衝突し、プリントベッドが物理的に損傷しました。再キャリブレーションのあとにテスト印刷をしたところ、問題なく動いているように見えますが、まだ気づいていない損傷があるかもしれません。

https://twitter.com/Ybalrid/status/1618583370199609348

  • X 軸(プリントヘッドの横方向の動き)が何度も左側に衝突し、モーターがガタガタと音を立てました。
  • X 軸がプリントベッドと平行でなくなりました。これはレベリングの工程で自動検出された問題です。私のプリンターには、可能な限り上まで移動することで自分自身を修正する工程があります。

これらの誤作動の原因は次のとおりです。

  • 除算の問題のせいで、うっかり NaN(非数)をプリンターの軸に送ってしまっていました。これは、Z 方向の移動を要求していないのに Z 軸が動いてしまうことさえあり、ノズルがベッドに衝突する原因になりました。
  • プリンターとの通信方法が間違っていました。プリンターに操作を送るたびに、プリンターが ok というメッセージをプログラムに返してくるのを待つ必要があります。

これらの失敗から学んだ教訓は次のとおりです。

  • 本番で初めて試すときは、リセットボタンを押すか、プリンターの電源を切れるようにしておく。
  • 新しいことを試すときは、プリンターを使って Z 軸を手動で中間あたりまで上げておく(プリンターで Settings > Move axis > Z を選び、ダイヤルで上げる)。Z 軸の移動は X 軸や Y 軸より遅いので、プリンターが望ましくない動作をしたときの猶予時間になります。
  • プリンターへの出力を防御的に検証する。 出力が想定範囲外の数値や、NaN や無限大といった不正な数値を送ろうとしている場合は、ただちにプログラムを終了する。なお、加熱の操作は一切行っておらず、常にプリンターから目を離さないので、先にプリンターのヒーターを切ることは気にしなくてよいです。
    通常のプログラミングでは、関数の出力ではなく入力だけを検証するのが普通ですが、今回は物理的な結果を伴う低レベルのハードウェアを扱うので、防御的なアプローチを取るほうがよいと考えるようになりました。
    private bool IsFloatIllegalRange(float value)
{
if (IsFloatInvalid(value)) return true;

return value < 0 || value > 210;
}

private bool IsFloatInvalid(float value)
{
return float.IsNaN(value) || float.IsInfinity(value);
}
  • プリンターに送る gcode はすべて、ok が返ってくるまでブロックする関数を通す。
    private void SafeWriteLine(string message)
{
_port.WriteLine(message);
WaitForOkResponse();
}

private void WaitForOkResponse()
{
string readLine;
do
{
readLine = _port.ReadLine();
Console.WriteLine($"Read line: {readLine}");
} while (!readLine.EndsWith("ok")); // FIXME: Maybe don't use EndsWith
}

QvPen をログに出力させる​

QvPen を改造して、毎フレーム、トレイルレンダラーの位置を出力するようにしました。QvPen は通常、線が完成したあとに描かれたすべての線を同期して、インスタンス内の全員に同じ絵が見えるようにします。しかし私は、描画の正確さを犠牲にしてでも、低レイテンシの効果を高めるために、描いている最中の線を出力する方法を選びました。

私は VRCDN を使って 3D プリンターの映像をワールドにライブ配信しているので、レイテンシは 1 秒未満で、とても素晴らしいです。

QvPen の変更点は次のとおりです。

// QVPen_Pen.cs
private void LateUpdate()
{
// ...
if (trailRenderer.gameObject.activeSelf)
{
var pos = inkPositionChild.position;
Debug.Log($"[GCODETRANSFER]::POS {pos.x:0.000000} {pos.y:0.000000} {pos.z:0.000000}");
}
}

private void StartDrawing()
{
// ...
Debug.Log($"[GCODETRANSFER]::START");
}

private void FinishDrawing()
{
// ...
Debug.Log($"[GCODETRANSFER]::FINISH");
}

これをログに記録するには、自分がワールドのインスタンス内にいる必要があります。

また、ペンの消しゴム機能をすぐに無効にするために、次の行も変更しました。

// QVPen_Pen.cs
private const float clickTimeInterval = 0.0f; // was 0.2 before

プレイヤーには正方形のホワイトボードに描いてもらうので、ペンがホワイトボードの平面に吸着するようにしました。

// QVPen_Pen.cs
public Collider[] alwaysBypassColliderMode;

private void OnTriggerEnter(Collider other)
{
var isAlwaysColliderMode = useSurftraceMode;
if (!isAlwaysColliderMode)
{
foreach (var bypass in alwaysBypassColliderMode)
{
if (other == bypass)
{
isAlwaysColliderMode = true;
break;
}
}
}

// Edit the following line to replace `useSurftraceMode` with `isAlwaysColliderMode`
if (isUser && isAlwaysColliderMode && VRC.SDKBase.Utilities.IsValid(other) && !other.isTrigger)
// ...

ホワイトボードの Box Collider を alwaysBypassColliderMode にドラッグします。

gcode への変換​

QvPen は、絵の中のどこに当たるかとは無関係に、ペンのワールド絶対座標を出力します。ワールド座標からプリンター座標への変換は、個人的な選択として、外部プログラム側で行っています。このステップで変換する利点は、精度が十分であれば、後からソフトウェア上でいくつかの誤りを補正できることです。

ログのパースは、FileStream、StreamReader、FileSystemWatcher を使って継続的に行っています。ほかにも方法はありますが、これといった好みがあるわけではなく、結果的にこれに落ち着きました。

QvPen が出力した行を絞り込めば、作業はほとんど終わったようなものです。

注意すべき点が 1 つあります。ユーザーが描き始めたときに、すぐにペンを押し付けてはいけません。次の位置を受信するまで待ち、そこへ移動してから、ペンを押し付ける必要があります。

処理全体はブロッキングで、非同期の魔法は使っていません。有効な行を読み取り、すぐにプリンターに送り、プリンターが ok と答えるのを待ちます。それから次の行を読み取り、というのを繰り返します。特に、受信したデータの後処理は一切行っていません。QvPen が出力したすべての位置が、そのまま gcode に変換されます。 簡略化のための工夫は何もしていません。本当に単純ですが、これでちゃんと動きます。

// Log parser
bool willPress = false;
void ParseLine(string line)
{
if (line.StartsWith("POS"))
{
var strings = line.Split(" ");
var x = float.Parse(strings[1]);
var y = float.Parse(strings[2]);
// FIXME: Whiteboard bounds
var xRel = Math.Clamp(x + 0.5f, 0f, 1f);
var yRel = Math.Clamp(y - 0.25f, 0f, 1f);
prusa.DrawTo(xRel, yRel);
if (willPress)
{
prusa.PressPen(1f);
willPress = false;
}
}
if (line.StartsWith("START"))
{
// The pen needs to move to the next drawing position before pressing
willPress = true;
}
if (line.StartsWith("FINISH"))
{
prusa.ReleasePen();
willPress = false;
}
}

ペンの取り付け​

ペンはマスキングテープで固定しています。そのうち、もっと頑丈な取り付け具を 3D プリントするかもしれません。キャリブレーションは手作業で行っています。ソフトウェア上でペンを押し付けたり離したりするのは、Z 軸をごくわずかに動かすことで行っています。

結果​

友達を招いて描いてもらったところ、ちゃんと動きました! ホワイトボードの座標をプリンターの座標に変換するところで間違いがありましたが、結果的にはうまくいきました。

ワールドには、低レイテンシを有効にした私の VRCDN アカウントにつないだライブ配信プレイヤーを置きました。レイテンシは驚くほど低く、効果をさらに高めています。

https://twitter.com/vr_hai/status/1618915110415896576

2022-04-04 字幕システムの振り返り

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

私のワールド「Offline」用に字幕システムを作りました。もともとは、YouTube の英語字幕を使って日本語の映画を観るために作ったものです。

字幕システムはすでにいくつか存在しますが、私が知っているものは映画のリストを前提に設計されています。このシステムは、任意の動画に対して、ユーザーが字幕を貼り付けられるようにすることに特化しています。

SubtitleManager.cs - Shared to twitter
Instantly share code, notes, and snippets. SubtitleManager.cs - Shared to twitter You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or window. Reload to refresh your session. Reload to refresh your session.

別のユーザーが、この実装をもとにさらに発展させてくれました。

https://twitter.com/loljacklul/status/1510610024925741065

ここでは、この字幕プレハブを作る中での経験を振り返ります。

字幕のフォーマット​

幅広くサポートされているように見えたので、字幕フォーマットには SRT(SubRip)を採用しました。簡単にパースでき、レイアウトに関する懸念もほとんどありません。

字幕は通常、表示される時刻順に並んでいます。これは私の Udon 実装における前提にもなっていて、パースした順序が表示順であると仮定しています。

このフォーマットの唯一の変わった点は、2 つのタイムスタンプの範囲が重なった場合にどうすべきかが、きちんと規定されていないことです( https://www.youtube.com/watch?v=FqJ21-GaYew )。

私が採った方法は、2 つの字幕が重なった場合に、複数の字幕テキストを改行で区切って表示するというものです。表示順は常にタイムスタンプ順で、「最後に表示された順」ではありません。

さらに、字幕 A と重なっている字幕 B が、A より後に始まって A より先に終わる場合、A が字幕 C と再び重なることはないと仮定しています。

コピー&ペースト​

🚫

この記事は 2022 年 4 月、つまりネイティブの String Loader が導入される半年前に書かれました。
今日では String Loader を使うほうが、おそらくより良い解決策ですが、このセクションには他の用途にも関係する情報が含まれているかもしれません。

私が作っているのは動画プレイヤーのワールドであり、映画のワールドではないので、あらかじめ決められた映画のリストはありません。字幕を事前に読み込むことはできず、ユーザーは好きな SRT 字幕を自由に使えます。

映画やドキュメンタリーの中には、字幕が非常に大きく、ファイルサイズが 300KB を超えるものがあります。しかし、Unity の Text フィールドは約 16K 文字までしかサポートしておらず、コピー&ペーストが失敗してしまいます。

これを回避するために、この制限のない TextMeshPro の Text フィールドを使い、ユーザーが途方もない量のデータを貼り付けられるようにしています。これにはいくつかの問題があります。

Udon からアクセスできない​

TextMeshPro のテキストには Udon からアクセスできません。テキストをコピーするには、Unity イベントを配線して、Unity UI の Text オブジェクトの Text 要素に設定する必要がありました。そうすれば、Udon から UI Text にアクセスできます。

テキストを表示すると、貼り付け中も貼り付け後もラグが発生する​

大量のテキストを貼り付けると、2 つの問題が起こります。

  • 貼り付けた直後に大きなヒッチが発生し、数秒待つしかありません。
  • 字幕テキストがフィールドに表示されている間は、パフォーマンスが大幅に低下します。日本語の 300KB の字幕ファイルでテストしたとき、この低下は顕著でした。

パフォーマンスを取り戻すには、送信した直後にテキストフィールドをクリアする必要があります。しかし、私自身はフィールドを簡単にクリアする方法を見つけられませんでした。もしかすると方法はあるのかもしれませんが、私にはわかりません。私が採った方法は、非表示のテンプレートとして置いてある Text フィールドの階層を複製(VRC Instantiate)し、それまで使っていた Text フィールドの階層を破棄することです。

同期​

🚫

この記事は 2022 年 4 月、つまりネイティブの String Loader が導入される半年前に書かれました。
今日では String Loader を使うほうが、おそらくより良い解決策ですが、このセクションには他の用途にも関係する情報が含まれているかもしれません。

ローカルユーザーが、同じ言語設定を持つインスタンス内の別のユーザーが貼り付けた字幕の恩恵を受けたい場合は、同期が必要です。

得られたアドバイスによると、およそ 60KB を超えるものをシリアライズしようとすると失敗することがあります。

これを回避するために、文字列を任意の文字数(8192 文字)で分割し、1 つずつ送信しています。文字列同期にかかるネットワーク上の制約を詳しく知らないため、文字数でのサイズは、バイト数の上限よりも意図的にかなり低く設定しています。

問題の規模を考えると、この数値を少し増やしたり減らしたりしても、同期の伝送にはそれほど大きな差は出ません。これは長い映画を観るためのものであり、字幕が毎分変わることはなく、プレイヤーもインスタンスに長く滞在することが想定されるので、同期速度は問題になりません。同じ理由で、文字列の圧縮も行っていません。この文脈では、それは作り込みすぎになるからです。

追跡​

表示するテキストを送る前に、動画プレイヤーの現在位置を追跡しています。

通常の動作中、字幕マネージャーは最後に表示した字幕のインデックスを記憶しています。その字幕と、(重なっているものがあれば)これから表示される他の字幕を表示し、完了したら次へ進みます。これにより、毎フレーム配列全体を走査せずに済みます。

動画プレイヤーが任意の地点まで巻き戻されたときは、字幕マネージャーが、配列のインデックス 0 から、現在時刻に一致する最初のタイムスタンプまで、すべての字幕のタイムスタンプを読み取ります。

表示​

字幕を表示するために、TextMeshPro の Text の 2 つのテクニックを利用しています。

  • 背景としての Mark( http://digitalnativestudios.com/textmeshpro/docs/rich-text/#mark )
    Mark を使うと、テキストに重ねて表示できます。通常は縁取りに使われますが、ここでは字幕の背景として使いたいのです。テキストをただ縁取るだけでは、テキストが暗くなってしまいます。
    これを実現するために、通常のテキスト用と、背景の表示専用の 2 つの Text オブジェクトを用意しています。
    背景用の Text には $"<mark=#000000>{buildingText}</mark>" を渡しています。
  • 黒い縁取りのための Outline( http://digitalnativestudios.com/textmeshpro/docs/shaders/ )
    テキストの視認性を高めるために、既存の背景の上に、字幕へ黒い縁取りを追加しました。これは、マテリアルの Outline 設定を変更することで行っています。

ディスレクシア(読字障害)​

上記の設定は、ディスレクシアのある方にもうまく合っているようです。

https://twitter.com/exe_elf/status/1496023742048313344

https://twitter.com/exe_elf/status/1496060055992639489

thank you! also thank you for making this yellow, that always makes it a bit easier to read subtitles with dyslexia for me
For me personally, rounder open fonts, and bright colors (like yellow!) work wonders, tysm 4 the consideration

ディスレクシアのユーザーにとってより識別しやすいとされるカスタムフォントを選べるオプションも追加しました。現在のフォントでも問題ないようではありますが。

攻撃のベクトル​

この字幕システムは、信頼できる環境で友人たちとくつろぐために使うことを想定しているため、攻撃への耐性を考慮した設計にはなっていません。

公開された環境では、悪意のある攻撃者が、悪意ある字幕ファイルを使って、大量のテキストを表示させてパフォーマンスを低下させようとしたり、問題のあるリッチテキストを字幕に使ったり、大量のテキスト切り替えを発生させたり、何らかの方法でネットワークを詰まらせてオブジェクトの所有権の問題を引き起こしたりする可能性があります。

このシステムが攻撃のベクトルとして使われるのを減らすために、さまざまな健全性チェックを導入する必要があるかもしれません。Udon のチャットボックスなど、ユーザー入力を受け付けるあらゆるシステムについても同じことが言えます。

2022-02-02 「Animation Viewer」の振り返り

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

「Animation Viewer」の開発中にいくつかの問題に遭遇したので、ここに記録しておきます。

Animation Viewer が生まれた経緯​

Animation Viewer は、私のプロジェクトである ComboGestureExpressions 2(仮称として ComboGestureDynamics とも呼んでいました)の開発中に、偶然生まれました。

アニメーションを CGE の UI にドラッグ&ドロップしやすくしたかったのですが、ドロップする前にアニメーションの内容を確認できないという問題がありました。

その時点で、Animator 上でアニメーションのプレビューを生成する仕組みはすでにありましたが、それは CGE の UI の内部でしか使われていませんでした。

そこで、Project ビュー上で直接アニメーションのプレビューを生成する方法を探し始めました。

失敗したアプローチ:RenderStaticPreview の利用​

AnimationClip 型用のカスタム Editor を作ろうとしました。

Editor を実装して RenderStaticPreview メソッドをオーバーライドすれば、マテリアルのように、アニメーションのプレビューをベイクして Project ビューに表示できると期待していました。しかし、思い通りにはいきませんでした。

RenderStaticPreview は、Inspector に表示される小さなサムネイルは更新してくれるのですが、なぜかアセット自体のサムネイルは更新されませんでした。

新しい Texture2D を返すと、アニメーションアセットが変化しなくなります。一方、AssetPreview.GetMiniTypeThumbnail が返す Texture2D を返すと、なぜか空白のアイコンになってしまいました。

さらに、原因のわからないクラッシュが何度も発生しました。

解決策を探すうちに、EditorApplication.projectWindowItemOnGUI コールバックを使う現在のアプローチにたどり着き、これでうまくいきました。この方法では、コールバックの中で項目をキューに追加します。項目が追加されると、次のフレームで、新しく割り当てたテクスチャにキュー内の項目を実際にレンダリングするタスクがスケジュールされます。

回避策:Animator が原点に移動する​

プレビューを生成するために、Animator の階層全体をコピーし、元のものを非表示にして、コピーを使って複数のアニメーションをレンダリングし、コピーを削除してから、元のものを再び表示します。

期待される結果は、コピーがその場でアニメーションすることです。しかし、理由はわかりませんが、最初のサンプリングの後に Animator がワールドの原点に移動してしまいます。Animator に RootT や RootQ のプロパティがないにもかかわらずです。

これを修正するために、コピーの位置と回転を記録しておき、サンプリングが終わった後でその位置を手動で再適用しています。

回避策:Muscle が更新されない​

アニメーションをサンプリングすると、なぜか、同じフレーム内で続けて AnimatorMode のサンプリングを行っても、Animator の Muscle が更新されません。その結果、すべてのアニメーションのサムネイルが同じ Muscle の位置になってしまい、正しくありません。

これを修正するために、試行錯誤の末、Animator の GameObject を無効にして、同じフレーム内で再び有効にすると、この問題を回避できることがわかりました。