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

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 つの位置が得られ、それを使ってワールド空間での手の速度を求められます。

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