Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NewMeshAPI_RuntimeMeshCollider

Unity の NewMeshAPI を利用して、ランタイムで MeshColliderSkinnedMeshRenderer の変形に追従させるサンプル。

GPU スキニング後の変形済み頂点バッファを compute shader で読み、AsyncGPUReadback で CPU に戻して MeshCollider に流し込む。アニメーションするキャラクターに対して、ボーンに紐づけた primitive collider ではなく実際のメッシュ形状で衝突判定を取りたいときの実装例。


What this sample documents

A runtime MeshCollider that follows a SkinnedMeshRenderer's deformation, using Unity's NewMeshAPI. The post-skinning vertex buffer is read by a compute shader, brought back to the CPU via AsyncGPUReadback, and fed into a MeshCollider.

The finding

The stride of the buffer returned by SkinnedMeshRenderer.GetVertexBuffer() differs per mesh. It is not a constant 40 bytes.

Articles and samples demonstrating this technique — including the first version of this one — commonly hardcode the stride to 40 bytes (float3 position + float3 normal + float4 tangent), a value observed once in RenderDoc. That value only holds for meshes that have tangents.

Measured on Unity 6000.5.6f1 (Windows Player) across the 15 skinned meshes in the demo scene:

stride count layout
40 bytes 13 position + normal + tangent
24 bytes 2 position + normal (no tangent)

One of the two 24-byte meshes is skin (2546 vertices) — the character's body and face, and the highest-vertex-count mesh in the scene. With a hardcoded stride of 40, the most visible mesh reads its positions from the wrong addresses. No exception is thrown and nothing is logged, so the breakage is silent.

The fix is to stop hardcoding and read GraphicsBuffer.stride at runtime, which is what this sample now does — see ValidateAndBindLayout in SkinnedMeshCollider.cs. When the stride is not a usable value the component logs an error and disables itself instead of dispatching.

You can predict the stride before you dispatch anything

The stride does not have to be discovered by capturing a frame in RenderDoc. The source mesh already knows it, and the public C# API will tell you. Mesh.GetVertexAttributes(), Mesh.GetVertexAttributeStream and Mesh.GetVertexBufferStride report which attributes a mesh has, which vertex stream each one is in, and how wide each stream is.

Dumping that for all 19 skinned meshes in the demo scene (the same Windows Player build as above) gives a completely uniform picture:

stream attributes, on every one of the 19 meshes stride
0 Position + Normal, plus Tangent on the 13 meshes that have one — always Float32 40 (13 meshes) / 24 (6 meshes)
1 TexCoord0, and TexCoord1 where present 8, or 16
2 BlendWeight + BlendIndices, where present 32, 16, or 4

(The scene has 19 SkinnedMeshRenderers but only 15 carry a MeshCollider, which is why the table further up counts 15. The four without one — EYE_DEF, BLW_DEF, MTH_DEF, EL_DEF, the face meshes — are all 24-byte. Counting every skinned mesh in the scene rather than only the collider-bearing ones, 6 of 19 break a hardcoded 40, not 2 of 15.)

On all 19 meshes, GetVertexBufferStride(0) equals the stride the deformed buffer reports at runtime. So the stride of the buffer you are about to read can be worked out from the source mesh, before the first dispatch, with no capture tool and no guessing — and the two meshes that come out at 24 are exactly the two that have no Tangent.

That is also the practical test if you suspect this bug in your own project: read GetVertexBufferStride(0) on the source mesh and see whether it is the 40 you assumed.

One project-wide setting also moves the value: Player Settings' Vertex Compression converts selected mesh channels from 32-bit to 16-bit floats (documented here). It is applied when the project is built, so a built Player can report a different stride than the same scene in the Editor. That is the concrete reason a value measured once, on one platform, in one project, is not a constant you can carry anywhere.

Player Settings' Mesh Deformation was measured in all three modes as well (CPU / GPU / GPU (Batched)): the stride is identical in all three. GPU (Batched) does not hand out a view into a shared batched buffer — GraphicsBuffer.stride returns the correct per-vertex stride directly.

One consequence for this sample: ValidateAndBindLayout requires the source mesh's Position to be Float32x3. Under aggressive Vertex Compression that assumption can fail, and the component will log an error and disable itself rather than read garbage. That is the intended behaviour, but it does mean this sample does not yet handle compressed positions.

Reported observations

The table below is what has actually been measured. It has one row. If you run this on another platform, graphics API, Unity version, or Vertex Compression setting, the value you see is genuinely useful — open a stride report and it gets added here. Nothing needs to be broken for the report to be worth filing.

Unity Target Context Vertex Compression Strides observed Reported by
6000.5.6f1 Windows / D3D11 Player (Development) project default 40 ×13, 24 ×2 maintainer

The demo scene's meshes are the Unity-chan model, so a report from a different asset set is at least as interesting as one from different hardware.

Unity 6 APIs this sample uses

The implementation is Unity 6 only. It is built on four APIs that did not exist in the 2021.2 version of this sample:

API What it replaces
AsyncGPUReadback.RequestIntoNativeArrayAsync returning Awaitable<AsyncGPUReadbackRequest> Polling AsyncGPUReadbackRequest.done from Update every frame
A persistent NativeArray as the readback destination The temporary buffer GetData<T>() returns, and the copy out of it
Physics.BakeMesh(EntityId, bool, MeshColliderCookingOptions) scheduled on an IJob Cooking the collision mesh synchronously on the main thread
MonoBehaviour.destroyCancellationToken + Awaitable.NextFrameAsync(token) Manual teardown flags

Physics.BakeMesh cannot be Burst-compiled — it P/Invokes into the PhysX cooking library — but it is callable from an ordinary job, which is enough to get it off the main thread.

Note that on Unity 6000.5.6f1 the older overload is not merely discouraged, it is an error:

error CS0619: 'Physics.BakeMesh(int, bool)' is obsolete: 'BakeMesh(int, bool) is obsolete. Use BakeMesh(EntityId, bool) instead.'
error CS0619: 'Object.GetInstanceID()' is obsolete: 'GetInstanceID is deprecated. Use GetEntityId instead. This will be removed in a future version.'

GraphicsBuffer.LockBufferForWrite looks applicable at first glance but is not: it writes into a graphics buffer, while MeshCollider cooking needs the CPU-side mesh data, so updating only the GPU buffer would not avoid the cook.

What the rewrite actually bought

Three variants were built and each run three times, 2400 frames per run, vsync off, in a Development Windows Player on Unity 6000.5.6f1 — same scene, 15 non-convex MeshColliders, 20 logical cores, RTX 3080 Laptop. Figures are main-thread frame time, averaged over the three runs:

variant mean median p95 collider updates/frame
A — the old Update state machine, cooking on the main thread 3.136 ms 2.221 6.115 0.342
D — the new Awaitable loop, still cooking on the main thread 3.026 ms 2.265 5.921 0.325
C — the new loop, cooking on an IJob (what this sample ships) 1.489 ms 1.203 2.596 0.320

A → D is −3.5%, which is noise. Rewriting the polling state machine as an Awaitable loop and reusing a persistent NativeArray instead of GetData<T>() bought essentially nothing in frame time; they are readability and allocation wins, not speed wins.

D → C is −50.8% mean and −56.2% p95, and D and C differ in collider update rate by only 1.5% (0.325 vs 0.320), so the drop is not an artifact of updating less often. The entire measured win comes from moving Physics.BakeMesh off the main thread.

Two caveats that matter more than the percentage:

  • Total CPU work is unchanged. The cook still runs; it runs somewhere else. This machine has 20 logical cores and a scene that barely uses them, so the worker threads were free. In a project whose job queue is already saturated, the win shrinks toward zero.
  • Comparing A directly with C is slightly confounded — their update rates differ by 6%. D exists precisely so the claim can rest on a matched comparison instead.

Notes

  • Requires Unity 6000.5.6f1 or later, Built-in Render Pipeline, and a target that supports compute shaders and AsyncGPUReadback.
  • Unity-chan assets are not included in this repository. Download them from unity-chan.com and import them yourself; see セットアップ below.
  • The main cost at runtime is MeshCollider cooking, not the readback. PhysX has no incremental cooking, so this sample moves the cook to a worker thread rather than avoiding it. See 既知の制約 below.
  • Measure before you copy this. The numbers above come from a 20-core machine with idle worker threads; that is the best case for this change, not the typical one.
  • Code is MIT (LICENSE). Assets you download remain under the Unity-chan License.

Full documentation follows in Japanese.


このサンプルが記録している知見

SkinnedMeshRenderer.GetVertexBuffer() が返すバッファの stride は、メッシュごとに異なる。

この手法を紹介する記事やサンプル(本サンプルの初版も含む)は、RenderDoc で観察した stride を 40 バイト(float3 position + float3 normal + float4 tangent)と決め打ちしていることが多い。しかしこれはメッシュに tangent がある場合の値でしかない。

Unity 6000.5.6f1 の Windows Player で実測した結果:

stride 件数 内容
40 bytes 13 position + normal + tangent
24 bytes 2 position + normal(tangent なし)

ユニティちゃんの 15 メッシュのうち 2 つが 24 バイトで、そのうち skin(2546 頂点)はキャラクターの体と顔そのもの、かつシーン内で最大の頂点数を持つメッシュだった。stride 40 の決め打ちでは、最も目立つメッシュの座標が別のアドレスから読まれる。しかも例外もエラーも出ないため気づきにくい。

対策は決め打ちをやめ、GraphicsBuffer.stride を実行時に読むこと。本サンプルはそう実装してある(SkinnedMeshCollider.csValidateAndBindLayout)。stride が期待どおりでない場合は Dispatch せず自身を無効化する。

stride は Dispatch する前に分かる

stride を知るのに RenderDoc でフレームをキャプチャする必要はない。元のメッシュがすでに知っていて、公開の C# API がそれを返す。 Mesh.GetVertexAttributes()Mesh.GetVertexAttributeStreamMesh.GetVertexBufferStride が、そのメッシュがどの属性を持ち、各属性がどの頂点ストリームにあり、各ストリームの幅がいくつかを返す。

デモシーンのスキンメッシュ 19 個すべてについてこれを出力すると(上と同じ Windows Player ビルド)、完全に一様な結果になる。

stream 19 メッシュすべてでの属性 stride
0 Position + Normal、加えて tangent を持つ 13 メッシュには Tangent。いずれも Float32 40(13 個)/ 24(6 個)
1 TexCoord0、ある場合は TexCoord1 8 または 16
2 BlendWeight + BlendIndices(ある場合) 32 / 16 / 4

(シーンには SkinnedMeshRenderer が 19 個あるが、MeshCollider が付いているのは 15 個。上の表が 15 なのはそのため。付いていない 4 個 — 顔の EYE_DEF / BLW_DEF / MTH_DEF / EL_DEF — はすべて 24 バイトだった。collider の有無を問わずシーン内の全スキンメッシュで数えると、stride 40 の決め打ちが壊れるのは 19 個中 6 個であって 15 個中 2 個ではない。)

19 メッシュすべてで、GetVertexBufferStride(0) は実行時に変形済みバッファが報告する stride と一致した。 つまりこれから読むバッファの stride は、最初の Dispatch より前に、元のメッシュから確定できる。キャプチャツールも当て推量も要らない。そして 24 になった 2 つは、ちょうど Tangent を持たない 2 つだった。

自分のプロジェクトでこの不具合を疑ったときの確認手順もこれになる。元メッシュに対して GetVertexBufferStride(0) を読み、想定していた 40 になっているかを見ればいい。

プロジェクト全体の設定でも値は動く。Player Settings の Vertex Compression は、選択したメッシュチャンネルを 32bit から 16bit float に変換する(公式マニュアル)。適用されるのはビルド時なので、ビルドした Player と Editor とで同じシーンでも stride が違いうる。 1 回・1 プラットフォーム・1 プロジェクトで測った値が持ち運べる定数にならない具体的な理由がこれ。

あわせて Player Settings の Mesh DeformationCPU / GPU / GPU (Batched) の 3 モードで切り替えて計測したが、stride は 3 モードすべて同一だった。GPU (Batched) でも共有バッチバッファのビューにはならず、GraphicsBuffer.stride がそのまま正しい頂点 stride を返した。

本サンプルへの影響がひとつある。ValidateAndBindLayout は元メッシュの PositionFloat32x3 であることを要求している。Vertex Compression を強くかけるとこの前提が崩れることがあり、その場合はエラーを出して自身を無効化する(誤ったデータを読むよりはよい)。つまり圧縮された position には未対応ということでもある。

実測の報告

下の表が実際に測られた値のすべてで、まだ 1 行しかない。 別のプラットフォーム、グラフィックス API、Unity バージョン、Vertex Compression 設定で動かしたときの値は本当に有用なので、stride report を投げてほしい。ここに追記する。不具合が起きている必要はない。

Unity ターゲット 実行環境 Vertex Compression 観測された stride 報告者
6000.5.6f1 Windows / D3D11 Player (Development) プロジェクト既定 40 ×13、24 ×2 maintainer

デモシーンのメッシュはユニティちゃんのモデルなので、別のアセットからの報告はハードウェアが違う報告と同じくらい価値がある。

動作環境

  • Unity 6000.5.6f1 以降(Unity 6)
  • Built-in Render Pipeline
  • compute shader と AsyncGPUReadback に対応した実行環境

Unity 6 専用。 AwaitableEntityId、ジョブから呼ぶ Physics.BakeMesh を使っているため、Unity 2021.2 では動作しない。初版(2021.2 向け)の実装はコミット履歴に残っている。

セットアップ

ユニティちゃんアセットはこのリポジトリに含まれていない。 ユニティちゃんライセンス条項に基づく第三者アセットのため、各自で取得する必要がある。

  1. このリポジトリをクローンする
  2. ユニティちゃん公式サイト から「ユニティちゃん 3D モデルデータ」をダウンロードする
  3. RunTImeMeshCol プロジェクトを Unity 6 で開く
  4. ダウンロードした .unitypackage をインポートする(配置先は Assets/UnityChan/ を想定しているが、アセット参照は GUID 解決のため実際のパスは問わない)
  5. Assets/Scenes/SkinnedMeshColliderDemo.unity を開いて再生する

再生すると、アニメーションするユニティちゃんに向けて球体が降り注ぎ、変形した MeshCollider に沿って跳ね返る。

仕組み

Start:   BakeMesh でインデックスバッファと初期姿勢を用意
         vertexBufferTarget |= GraphicsBuffer.Target.Raw
         衝突用 Mesh を座標のみのレイアウトに設定(12 バイト/頂点)
         読み戻し先の NativeArray を Persistent で確保(以後使い回す)
         RunAsync() を起動

RunAsync ループ(destroyCancellationToken で畳まれるまで):
  await Awaitable.NextFrameAsync(token)
  → GetVertexBuffer() で変形済みバッファを取得
  → 初回のみ GraphicsBuffer.stride を実測して compute shader へ渡す
  → compute shader で座標だけを抽出(ByteAddressBuffer として読む)
  → JobHandle.Complete()             … 前の周回で投げた cooking をここで回収
  → MeshCollider.sharedMesh          … cook 済みなので代入時に再 cook は走らない
  → await AsyncGPUReadback.RequestIntoNativeArrayAsync(ref 読み戻し先, ...)
  → Mesh.SetVertexBufferData
  → BakeMeshJob.Schedule()           … PhysX の cooking をワーカースレッドへ
  (ループ先頭へ戻る)

cooking の回収を Schedule の直後ではなく次の周回の頭に置いているのは、直後に await NextFrameAsync を挟んで待つと 1 周が 1 フレーム長くなるため。_mesh を触らない区間(NextFrameAsync + TryDispatch)がそのままワーカースレッドの持ち時間になる。

Physics.BakeMesh の引数は MeshColliderconvexcookingOptions に合わせている。既定値で bake すると、collider が非既定の cooking 設定を持つ場合に cache が一致せず結局再 cook になるため。

compute shader 側の頂点読み出しは MinimalComputeSkinnedMeshBuffer を参考にしている。

既知の制約

  • MeshCollider の cooking コストが支配的。 Mesh の頂点座標を更新すると collision mesh が dirty になり、PhysX の triangle mesh が再生成される。デモシーンは非 convex な MeshCollider を 15 個抱えているため、毎フレーム 15 回の cooking が走る。
  • PhysX に増分 cooking は無い。 頂点を 1 つ動かしても midphase の空間分割構造は全体が作り直される。したがってこのサンプルが行っているのは cooking を減らすことではなく、Physics.BakeMesh をジョブに載せてメインスレッドから追い出すことだけ。総 CPU 仕事量は変わらない。さらに削るには更新頻度を落とす、衝突専用の低ポリメッシュを使う、変形量が閾値を超えたときだけ再 cook する、といった対策が要る。
  • 書き直しで実際に効いたのはジョブ化だけ。 3 変種を各 3 回、2400 フレーム・vsync off・Development Player(Unity 6000.5.6f1、20 論理コア、RTX 3080 Laptop)で計測した結果(メインスレッドのフレーム時間、3 回平均):
変種 mean median p95 更新率
A 旧実装(Update の状態機械、cooking はメインスレッド) 3.136 ms 2.221 6.115 0.342 回/f
D 新ループ(Awaitable)だが cooking はメインスレッド 3.026 ms 2.265 5.921 0.325 回/f
C 新ループ + cooking をジョブへ(本サンプルの実装) 1.489 ms 1.203 2.596 0.320 回/f

A → D は −3.5% で誤差の範囲。ポーリングの状態機械を Awaitable に書き直したことも、GetData<T>() をやめて NativeArray を使い回すようにしたことも、フレーム時間にはほとんど効いていない(可読性と確保回数の話であって、速度の話ではない)。D → C が mean −50.8% / p95 −56.2% で、しかも D と C の更新率の差は 1.5%(0.325 対 0.320)しかない。つまりこの低下は「更新回数が減っただけ」ではなく、Physics.BakeMesh をメインスレッドから外したこと自体の効果である。

ただしこの数値をそのまま持ち込まないこと。 総 CPU 仕事量は変わっておらず、cooking は別スレッドで走っているだけ。計測機は 20 論理コアでシーンがそれを使い切っていないため、ワーカースレッドが空いていた。ジョブキューが既に埋まっているプロジェクトでは効果はゼロに近づく。実プロジェクトでは必ずプロファイラで測ること。

  • Mesh.MarkDynamic() は graphics buffer 更新のヒントであり、この cooking を省略する仕組みではない。
  • 破棄時に読み戻しの完了を待つ。 destroyCancellationTokenawait を畳むだけで、GPU 側の読み戻し要求までは取り消さない。読み戻し先の NativeArray を解放する前に AsyncGPUReadback.WaitAllRequests() で完了を待つ必要があり、ここだけはメインスレッドが止まる。
  • 変形済みバッファが用意される前(初回スキニング前、画面外かつ updateWhenOffscreen == false、cached mesh 未準備)は GetVertexBuffer()null を返す。画面外でも常に更新したい場合は updateWhenOffscreen = true が必要。

ライセンス

  • このリポジトリのコードAssets/*.csAssets/Resources/*.compute、シーン、プレハブ、マテリアル)は MIT License
  • ユニティちゃんアセットは含まれていない。 各自でダウンロードしたアセットはユニティちゃんライセンス条項に従う。デモシーンはユニティちゃんのモデルを前提としているため、実行するにはこの条項への同意が必要になる。

ユニティちゃんライセンス

このサンプルはユニティちゃんを利用しており、ユニティちゃんライセンス条項の元に提供されています。

コントリビュート

CONTRIBUTING.md を参照。

About

Runtime MeshCollider that follows a SkinnedMeshRenderer's GPU deformation (Unity 6, NewMeshAPI). Documents why the deformed vertex buffer stride is not a constant 40 bytes.

Topics

Resources

Code of conduct

Contributing

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages