設計
このパッケージがなぜこの形をしているかの記録です。実装を読めば分かることは書かず、
読んでも分からない「なぜ他の形にしなかったか」を残します。
なぜ別パッケージなのか
このパッケージは Udon を必要とします。io.github.sabas0ba.sabaprops.foliage はvpmDependencies が空で、VRChat SDK が無いプロジェクトでも動くことを保ちながら、
SDK があるときだけリフレクションで VRCSceneDescriptor を置きます。同居させると
その性質が壊れます。
同じ線引きは Foliage 側で既に引かれています。移動速度を変えるサンプルがSamples~ に置かれているのは、「インポートすること自体が UdonSharp を引き込む」
ためでした。カメラリグはその境界の向こう側にあります。
ソルバと収集層の分離
UdonSharp は UdonSharpBehaviour を継承したクラスしかコンパイルしません。
ユーザー定義の static ヘルパークラスや struct は Udon から呼べないため、
Foliage の FoliageMeshBuilder のように「純粋な生成器を別クラスへ切り出す」構成が
取れません。
代わりに 部分クラス に分けています。SDK 3.10.4 の UdonSharpCompilerV1 は
同一クラスの partial 宣言を受け付けます(プログラムアセットを持てるのが 1 つだけ、
という制約付き)。
StageCamRig.cs— VRChat から値を取り出し、Transform へ書き戻す側StageCamSolver.cs— 幾何計算。基底型も VRC のusingも持たない
後者はこのファイルだけで単独コンパイルできます。それを利用して.github/verify/offline/OfflineStageCamTests.cs が、Unity も VRChat も無い環境で
追従計算そのものを実行します。検査側も同じクラスの partial として書いてあるので、
ソルバのメソッドを private のまま検査できます。
成立条件はひとつだけです。
ソルバのメソッドは StageCamRig のフィールドを参照してはなりません。
必要な値はすべて引数で受け取ります。
姿勢の表現に quaternion を使わない
カメラの姿勢を「方位角・仰角・距離」の 3 つのスカラと、注視方向からのずれを表す
2 つのトリムで持っています。Quaternion.LookRotation は使っていません。
理由は 3 つあります。
roll が構造的にゼロになります。 軌道上のカメラから見た被写体の方向は必ず軌道方向の
逆なので、注視回転は方位を 180 度回して仰角をそのまま使うだけで求まります。Quaternion.Euler(pitch, yaw, 0) と同じ合成順で組めば roll は入りようがありません。
傾いた地平線は映像として明確な欠陥なので、設定で防ぐのではなく表現で防ぎます。
退化点がありません。 LookRotation は視線とワールド上方向が平行になると解が
決まりません。真上や真下からのアングルはステージのカメラでは普通に使うので、
そこで破綻する表現は使えません。
平滑化が単純になります。 角度のスカラを DeltaAngle で補間するだけなので、
360 度の折り返しを跨いでも最短方向へ寄ります。Slerp の経路選択を考えずに済みます。
代償は roll を保持できないことです。Pickup でカメラを傾けて離しても水平に戻ります。
これは失うものより得るものが大きいと判断しました。
Pickup 補正を「離したときの処理」にしない
掴んでいる間、毎フレーム姿勢を軌道パラメータへ焼き戻しています。離すためのイベントを
待っていません。
OnDrop で 1 回だけ焼き戻す実装もあり得ますが、そちらは掴んだまま被写体が動いた場合に
壊れます。焼き戻しはターゲット点を基準に測るので、基準が動いている間ずっと更新して
おかないと、離した瞬間の値が古い基準に基づいたものになります。
毎フレーム更新なら、離すのは単に「焼き戻しをやめて適用を再開する」ことでしかなく、
状態遷移が 1 つ減ります。
座標系の往復が壊れないこと
Pickup 補正の正しさは、次の往復が成り立つかどうかに尽きます。
world 姿勢 → (方位, 仰角, 距離, 方位トリム, 仰角トリム) → world 姿勢
これは数値的に検査できる性質なので、オフライン層で毎 PR 確認しています
(baking a grabbed pose reproduces it)。roll は意図的に捨てるため、向きは
quaternion ではなく forward ベクトルで比較します。
同期しない理由と、同期させるときの形
0.2.0 まではローカル専用です。ただし状態はすべて float と int で持っており、
同期させる準備はできています。
同期させるときも Transform は同期しません。同期するのは次のパラメータだけです。
- 対象の
playerId - 部位と追従モード
- 方位・仰角・距離・トリム
- 画角と平滑化の設定
各クライアントが自分でボーンを読み、自分で Transform を解きます。帯域は状態変化時のみで、
late joiner は OnDeserialization で追いつきます。Quaternion は Udon の同期可能型に
含まれているので、必要ならそのまま載せられます。
Transform を同期すると、毎フレーム分の帯域を使ったうえで、受け取った側では補間の
かかった位置しか得られません。パラメータを配れば各クライアントがローカルの
フレームレートで滑らかに解けます。
PostLateUpdate を使う理由
ボーンの姿勢は IK とアニメーションが適用された後に確定します。Update で読むと
1 フレーム古い値になり、被写体が動くたびにカメラが遅れて揺れます。UdonSharpBehaviour.PostLateUpdate は SDK 3.10.4 に存在することを確認済みです。
UdonSharp のためにパッケージが同梱するもの
Runtime には C# 以外に 2 つのアセットが入っています。どちらも無いと、利用者の
プロジェクトでコンポーネントを付けた時点で壊れます。
| アセット | 役割 |
|---|---|
SabaProps.StageCam.Runtime.asset | UdonSharpAssemblyDefinition。このアセンブリが U# のものだと UdonSharp に伝えます。無いと「does not belong to a U# assembly」でコンパイル自体が始まりません |
StageCamRig.asset | UdonSharpProgramAsset。挙動 1 つにつき 1 つ必要です。無いと AddComponent した時点で「Unable to find valid U# program asset」になります |
StageCamRig.asset の serializedUdonProgramAsset は空にしてあります。実際の
Udon プログラムは取り込んだ側のプロジェクトの Assets/SerializedUdonPrograms/ に
作られるため、こちらのプロジェクトで作られたものへの参照を配ると、
利用者の環境では解決しない GUID になります。
どちらも、実物の Unity で動かすまで存在に気付けないものでした。 C# として
コンパイルが通ることとは無関係で、オフライン層は素通しします。この 2 つが揃って
いることは .github/verify/vrchat/Tests/StageCam/ が clean なプロジェクトから
確認します。
検証の当て方
| 層 | 何を確かめるか |
|---|---|
.github/verify/verify.sh | Runtime が実物の VRCSDKBase.dll に対してコンパイルできること。ソルバを実行して幾何の性質を検査すること |
.github/verify/vrchat/ | UdonSharp が実際に Udon アセンブリへコンパイルできること。同梱アセットが揃っていること。想定したイベントとフィールドが Udon 側に出ていること |
オフライン層で UdonSharpBehaviour だけは手書きスタブです。SDK 側ではVRC.Udon と OdinSerializer とコンパイラライブラリを引き連れたソースであり、
この層で組み直すものではありません。UnityEditorStub.cs と同じ性質の穴で、
同じように上の層が塞ぎます。
ClientSim ではリモートプレイヤーを作れません。 そのため、他人のボーンの補間・遅延と
同期の伝播はどの層でも検証できず、実機でしか確かめられません。
ClientSim は PostLateUpdate を発火しません。 SDK 3.10.4 の ClientSim ランタイムに
このイベントを送る経路はありません。したがって「実際に追従する」ことは自動では
確かめられず、確かめられるのは _postLateUpdate が Udon のイベントとして
エクスポートされていることまでです。追従計算そのものはオフライン層が実行して
検査しているので、残る未検証部分は「VRChat がそのイベントを毎フレーム送ること」
だけになります。