こんにちはphi16です。この記事は VRChat Advent Calendar 2019 の13日目の記事です。
昨日の記事はあずりてさんの VRChatにおける運動とその効果 でした。
VRChatという環境では、フルのUnityに比べて出来ることがとても制限されています。とても。健全なマルチプレイヤーVRプラットフォームを保つ為に強い制約を掛けながら、しかし本質的に必要である機能には制限を掛けないことによってVRChatではとてもたくさんの表現が実現可能となっています。
今回紹介するのはその必要である機能の1つと考えられる「物体の移動」です。当然、物は、動きます。何故なら動かしたいので。しかし、それには物を動かすことを指示する誰かが居なくてはなりません。そしてその指示の仕方も。
簡易的なレイヤとしてVRChatが用意した1つの物体移動手段は次です。
VRC_ObjectSync.TeleportTo
- VRC_TriggerからObjectSync付きオブジェクトを指定して発火
- 移動先はTransformとして指定、発動によって位置と姿勢をコピー
これはObjectSync付きの物体を移動する手段で、(基本的には) 全プレイヤー間で同期する物体を特定の位置に移動させることができます。
しかし、複雑な機構の為には必然的にLocalでの移動が必須となります。これは同期を行う為であったり、同期を行わない為であったりしますが、この移動手段だけでは不十分と言えるでしょう。しかも (昔に) 不具合が幾らか報告されており、動作が自明であるとは言い難い状況にあります。
もっと自明な方法としては当然次があります。
Animation / Transform
- VRC_TriggerからAnimationパラメータ系Eventを発火
- Animationとして物体のTransformのプロパティを弄ったりすることができる
Animatorは自由に扱えるので好きに物体位置を弄ることが出来ます。ただしこれは当然Animationを作成した段階の固定値に向かうものであり、もっと「柔軟な」動きをするにはちょっと物足りません。
物理系は制御が面倒なので (正確には私は制御できないので) 割愛します。ごめんなさい。
ところでVRChatが将来的に導入しようとしているプログラミング環境としてUdonがあります。とりあえずどう考えても柔軟にいろんなことができるのは明らかですが、そう考えてみると逆に今足りない物として想起させるものが幾らかあります。演算です。
物体の移動は出来ても位置と微小移動を足し合わせるようなことは、先の方法では常識的には出来ません。
が、VRChat Vector Mathematics という記事があります。Transformの親子関係をうまく使って演算を行おうというのです。この原理を使って実現されていたのが CyanClimbing prefab でした。
…なんだか十分いろんなことができる気がしてきました。というわけで色んなComponentを使いながら物体を好き勝手に移動する方法について、これから書いていきます。
乗れるローレンツアトラクタができました pic.twitter.com/OnPIAXYXVZ
— phi16 (@phi16_) November 28, 2019
目次
- Button / uGUI
- Components
- VRC_Scene Reset Position
- Follow Target
- Lookat Target
- Protect Camera From Wall Clip
- 演算の実現
- 可逆な線形操作
- 射影
- グリッドスナップ
- 絶対値
- 乗算器
- 実践: ローレンツ方程式を組む
- 終わりに
Button / uGUI
まず自由に「あるメソッドを発火させる」方法として、Buttonコンポーネントが使えます。「正しい説明」は いろんな 文献 に任せるとして、この上でプログラムを書くということについて考えます。
ButtonのonClick
の発火は「Animation Event / Press()
」か「Buttonのイベント発火」から行うことができます。Animation EventはAnimationで好きなときに発火できますが、某ToyboxにはAnimatorのプリセットが用意されています。
- EventSelectorAlways: そのGameObjectのButtonの
onClick
を毎フレーム発火するAnimator - EventSelectorManual:
Trigger
というAnimationTriggerによってonClick
を発火するAnimator
とても便利です。というわけで好きなタイミングでonClick
が呼べます。(ちなみにGameObjectを非ActiveにするとAnimatorが動かなくなるので発火されなくなります)
後はonClick
のイベント欄に好きなメソッド呼び出しを書き連ねるだけです。デフォルトだと幾つかのメソッド (Button.Press()
とか) が呼べなかったり操作が面倒だったりするので MerlinさんのEasyEventEditor を私は使っています。(この辺はLocomotionの解説に書いてあった)
Components
移動に使えるComponentは先に上げた物以外にも幾らかあり、それらは特にほぼ挙動が明らかであるという点で非常に使いやすいものになっています。
VRC_Scene Reset Position
- uGUI
VRC_SceneResetPosition.ResetPosition()
で発火 - 参照したTransformの位置・姿勢・スケールをコピー
VRChat SDK では Legacy Component と扱われ、ドキュメントからも消された Component ですが、動きます。まぁ動くからといって使っていいわけではもちろんないわけですが、少なくとも現在動いています。
挙動は明らかで、同期とかも何もしないのでとても扱いやすいです。
Follow Target
- 基本的には毎フレーム発火
- 参照したTransformの位置のみをコピー、グローバル座標でのオフセットを与えることも可能
- 非ActiveにするとuGUIの
FollowTarget.LateUpdate()
から呼べる
VRChatで動く数少ないスクリプト群の一つである Standard Assets の中でも特に扱いやすいものです。位置だけ書き込むという特性は VRC_Scene Reset Position とは違う役割に便利で、ぐにゃぐにゃにスケールを変化させてしまった場合でもまともな点の位置のみを拾い出すことができます。
Lookat Target
- 発火は一定間隔 (Fixed Update) か毎フレーム (Late Update) か手動 (Manual Update, uGUI
LookatTarget.ManualUpdate()
) か選べる - 指定した物体をローカルのZ軸が向くようにGameObjectを回転
- 演算器として使う場合には Rotation Range は
(360,360)
, Follow Speed は0
で良さそう
これは回転のみを変化させるものです。向き $S^2$ だけでは回転 $SO(3)$ は決定しませんが、初期姿勢のローカルY軸が上方向だと認識して計算するようです (特異点がありますが、気にすることはありません)。
何らかの方法で計算したデータから向き情報だけを取り出すことができます。
Protect Camera From Wall Clip
- 基本的には毎フレーム発火 (
LateUpdate()
からも呼べる) - Camera Componentが取り付いている場合、レイキャストした衝突点まで位置を移動させる
- 演算器として使う場合には Clip Move Time / Return Time / Closest Distance は
0
、Sphere Cast Radius は1e-05
とかで良さそう
そういえば レイキャストの実現があった と思ったので諸々読んでみたところ普通にレイキャストが使えました。完全に目的外利用ですが、動いているので問題ないでしょう。
親Transformの -Z 方向に向かってレイを飛ばし、その衝突点にGameObjectが移動します。衝突しないか初期位置よりも遠かった場合は初期位置に移動します。なので初期ローカル位置を (0,0,-1000)
とかにしておくと1000mまでレイを飛ばせるはずです。向きもそれっぽく設定されます (目的が目的なので)。
他にも Standard Assets には物体を動かす手段があったと思いますが、とりあえず今回の最終目標には十分なのでこれで。
演算の実現
可逆な線形操作
ここでの線形な操作というのは同次座標上での4x4行列による作用のことで、平行移動・回転・定数倍などなどです。その中でも元に戻せるものは実現が明らかとなります。
Hierarchyとして親子関係を仮定し、親のTransformを $O$ とします。子のTransformを $X$ とした状態で親のTransformを $Y$ にすれば、子のTransformは $O$ を基準とすれば $YX$ となります。
基本的には特定のTransformを原点 (標準基底) として扱うことで行列積が計算できると捉えることができます。ここで可逆性を仮定しているのはTransformとしては基本的には可逆なものがくるべきだからです。
で、じゃあ具体的に何ができるかという話で。
- $Y$ として位置
(0,0,z)
となるようなTransformを与えれば子のZ座標がz
増えたり。 - スケール
(2,2,2)
となるTransformを与えれば子の座標が $O$ の原点を中心に2倍になります。
また、順序を逆にして、親を $Y$、子を $X$ にしてから親を $O$ にすると子のTransformは $Y^{-1}X$ となります。これで引き算もできますね。
複数回操作を順番に行う場合も同様で、Hierarchyで親から P→Q→R→S みたいに組んでから「親から順番にリセット」「最下層を変更」「子から順番に変更」していけば複数の操作の結果を計算できます。
「Transformを変更」というのはここでは基本的には VRC_Scene Reset Position を指していて、そういう意味で最も普遍的に使える Component である Scene Reset Position を基準にコードを書いていく、SceneResetPositionプログラミングをすることになります (私が勝手に呼んでいます) (冗談で)。
とは言えスケール変化とかをするとそれが全て伝播してしまうのはだいぶ困った性質で、位置のみを基準として運用しているのならほぼ Follow Target で同様のことをすることになるのであまり Scene Reset Position ばかりというわけではない気もします。
ここまでが VRChat Vector Mathematics の話。
射影
特定の位置から座標のある成分のみを抜き出す方法として、UnityのTransformの持つちょっとした仕様を使うことができます。
親のスケールのある成分のスケールを0にした場合、子を特定の場所に移動させると該当する成分だけが0であるような位置に移動する。つまり親のスケールを (1,0,0)
にして子を (X,Y,Z)
に移動させると実際には (X,0,0)
に移動します。1つ潰せば平面上に、2つ潰せば直線上にのみ移動することになります。本来なら可逆じゃないのでこの演算自体が厳しいはずですがUnityさんは優秀です。すごい。
というわけで座標を取り出したければ他を潰せば良いのです。スケールがぐちゃぐちゃになるのでそこから値を取り出すには Follow Target を使うのが良いです。
グリッドスナップ
またUnityのTransformのちょっとした仕様を使うと整数部分を取り出すことができます。
スナップはね、浮動小数点では大きい数字で精度が低いことで作りあげました。例えば10000000.4という値は浮動小数点で10000000になります。
— Jason Lim (@JasonL663) May 29, 2018
親→子
という関係で、親オブジェクトのXYZ位置を10000000にして、そして子オブジェクトを-10000000にすることで、この振る舞いが原点の辺に使う事が出来ます
いわゆるfloat型というのは値が大きくなればなるほど精度が落ちる性質を持っていて、8388608
から 16777215
まではちょうど1ずつの刻み幅しか値を取れません。つまり親の位置を10000000くらいにして子を Follow Target すると、丁度「最も近い整数座標」に移動します。
境界値の中間である (12582912,12582912,12582912)
に設定すれば±4194303mの範囲くらいは正常に取れるので、実用には困らなそうです。実用ってなんだろう。
絶対値
Lookat Target を使った変な機構の例として絶対値の計算ができます。
- 親: $P$ を向くLookat Target・Y軸を向くLookat Target
- 子: $P$ へのFollow Target
$P$ の位置が (0,y,0)
しか来ないとすると、次の操作によって子が (0,|y|,0)
の位置に移動します。
- 親: $P$ を向く
- 子: $P$ に移動
- 親: Y軸を向く
y>0
のとき親の向きは変化しないので子の位置は変化しませんが、y<0
のとき親は -Y方向 を向きます。その状態で子を移動すればそのローカル座標は (0,-y,0)
となり、親がY軸を向けばちゃんと値が取り出せる状況になるわけです。なお y=0
で特異点になりますが細かいことは気にしないでいきましょう (ちょっとずらせば大丈夫だと思います)。
これを使えば max(x,y) = (x+y)/2 + |x-y|/2
や min(x,y)
も作れますね。
乗算器
決まった定数を乗することはスケールを与えることで実現可能ですが、座標と座標を掛け合わせることは自明ではありません。というかこれは線形変換ではありません。
そこで Protect Camera From Wall Clip を使います。次のようにセットアップします。
- 原点から横に「1」離れた場所に点を用意、そこから上方に $X$ 移動した点を用意し、Lookat Targetで原点を見る
- 原点から先とは逆向きの横に $Y$ 移動した点を用意、そこに縦に巨大なBox Colliderが来るように設定 (子にすれば良い)
- Lookat TargetしたGameObjectの子からレイキャストしてBox Colliderとの衝突点を計算
みんな大好き相似の三角形ができます。つまり衝突点の高さは $-XY$ となるわけです。乗算ができました。ウケる。
注意点としては $X$ は負でも良いが $Y$ は正の値 (正確には -1 以上)でなければならないという点です。これは $Y+O_Y$ としてオフセットを加えて $O_Y$ と同時に2つレイキャストを飛ばして差分を取る ($-X(Y+O_Y) + XO_Y = -XY$) 方法が使えます。
あとBox Colliderの高さを10000くらいにしたらうまく動かなくなったので1000くらいに収めると良いです。それだと値がはみ出てしまう可能性がありますが、例えば「1」の長さを10mにすると出力が $-XY/10$ になるのでうまく調節すれば範囲に収められる (あとは定数倍で復元) と思います。
実践: ローレンツ方程式を組む
ローレンツ方程式というのは次の非線形な常微分方程式のことです。
$$\begin{cases}\displaystyle\frac{dx}{dt}=-px+py\\\displaystyle\frac{dy}{dt}=-xz+rx-y\\\displaystyle\frac{dz}{dt}=xy-bz\end{cases}$$
$p, r, b$ は定数で予め決まったもの (有名なのが $10, 28, 8/3$) となります。これをオイラー法で更新していけばとりあえず物体がベクトル場に沿って動いていくことになります。C#が書ければマジでこの通り書くだけなんですが、VRChatなので単純には書けません。
線形な部分はどうにでもなりますが、内部に $-xz$ と $xy$ があるので乗算器を取り出してくる必要があります (この為に乗算器の作り方を考えました)。おかげでとても面倒です。が、出来ます。
某ワールドは最初はローレンツアトラクタに乗りたいという唯それだけの為につくる予定だったんですが、気づいたらあんなことになってました。
全体の流れ
- 入力系
-
Target
を現在の位置とする
-
- 計算系
- 計算機座標に移動
- 座標に分解
- 乗算器を実行
- 各項を計算・総和を取る
- 位置に再構成
- 配置座標に書き戻し
- 出力系
-
Target
を書き換える
-
実装です pic.twitter.com/t5N13BwykG
— phi16 (@phi16_) November 28, 2019
丁寧に実装を説明するとヤバいので、「やりたいこと」を順番に追っていくことにします。
入力フェーズ (2行目)
- 配置空間 → 計算機空間 → 方程式座標空間へ移動 (1-5)
- 入力位置をコピー (6)、軸に分解 (7-9)、全てY軸上の座標に (10-16)
- 軸分解は射影
- Y軸上に持っていくのは単に回転をしています
- 乗算器を起動 (17)
乗算フェーズ (3行目)
$xy$ と $xz$ を同時に計算したいので、横移動を $x$、縦に $y$ と $z$ を同時に配置しています。オフセットずらす部分は奥に1mずらしたものを配置して使っています。
- $y$と$z$をコピーして横に定数 (10m) 移動 (1-4)、原点を向ける (5-8)
- $x$をコピーしてオフセットずらす (9-11)
- レイキャストを実行 (12-15)
- オフセット分の引き算を実行 (16-21)
- ずらした定数分を復元しつつ出力 (22-25)
- 総和演算器を起動 (26)
総和フェーズ (4行目)
- $y$ → $x$を引く → $p$倍する → X軸を向ける (1-7)
- $x$ → $r$倍する → $y$を引く → $-xz$を足す (8-14)
- $0$ → $z$を引く → $b$倍する → $xy$ を足す → Z軸を向ける (15-23)
- 出力器を起動 (24)
これで各々の速度成分がちゃんと各々の軸に向いた状態になります。
出力フェーズ (5行目)
ちなみに $dt$ は $0.003$ で、つまりスケールが 0.003
のGameObjectです。
- Z軸の出力 → Y軸の出力を足す → X軸の出力を足す → $dt$倍する → 元の座標を足す (1-9)
- 方程式座標空間 → 計算機空間 → 配置空間へ移動 (10-12)
- さっき動かしたので初期化は不要
-
Target
を最終出力へ移動 (13)
できました。おめでとうございます。
こんなやつのはなしです pic.twitter.com/xZJvtL1gmo
— phi16 (@phi16_) December 12, 2019
おまけ: Locomotion (1行目)
折角なので掴んで乗れるようにしたいと思います。というかそのために略。
人を好きなように移動させるLocomotionの基礎は Locomotion system を自作するためのガイド を読むと良いと思います。今回行わなければならない変更は次のみです:
- 現在のPickup位置を取得 (8)
- それを入力として先程のプログラムを起動して1ステップ計算 (9-11)
- その移動差分をプレイヤーの位置差分として加算 (12-14)
- プレイヤーの移動が終わったらPickupの位置を1ステップ計算後の位置に移動 (18)
あとは掴んだときに有効化・離したときに無効化と位置リセットを加えれば完成です。お疲れさまでした。
終わりに
スクリプトを書かずにローレンツ方程式に従ってGameObjectを動かす方法について説明しました。**当然、この記事はUdonが来れば技術的には用済みになります。**なのでこの記事は歴史書に近いと思います。真に受けないでください。
こんな記事が必要にならないことを願っています。はやくUdonきてくれ~~~~~~~~
明日はhatsucaさんの記事です。とても楽しみです。
追記
出来ました #MadeWithUdon pic.twitter.com/g95GBWyfrY
— phi16 (@phi16_) December 20, 2019
Udonが来ました。これが本来のプログラムです。本当にありがとうございました。
おまけ: Pickupの注意点
物体をPickupで掴んだとき、その位置を「コントローラの位置」にするには Exact Grip が使えますが、だとしても掴んだ瞬間は位置が安定しません (元の場所にいる)。つまりOnPickupの中のActionでは手の位置は取れません。しょうがないので適当に遅延させるなどする必要があります。
というわけで「掴んだ場所を基準にした差分を使って動かす」みたいなことをするにはこれを対策しないといけないと思います。ただ私がひっかかっただけですが、おまけとして。