Imaginantia

思ったことを書きます

Udon/U#でバケツの取っ手を揺らす

こんばんは。今回はこちらのバケツの取っ手を揺らしていこうと思います。

f:id:phi16_ind:20200422183629p:plain

出来上がったものがこちらになります。


まずモデルを用意します。今回は偶然 Cap. に頂いたバケツのモデルを利用します。こちらのモデルは非常によく出来ていて、取っ手のMeshが分離されており、かつモデル原点が回転軸上にあり、またその回転軸が丁度X軸になっています。これは即ち、取っ手の取り得る「座標」がX軸回転の角度のみで表せる、ということです。

f:id:phi16_ind:20200422192607g:plain

「バケツの取っ手」というものは、その端点2箇所が固定されている結果、動くことのできる向きが1つの角度方向しか無いという1自由度の系になっているのです。そのパラメータ表現が今回は丁度X軸回転と一致するので、扱いやすいということです。

f:id:phi16_ind:20200422191801p:plain

Hierarchyはこんな感じになっていて、バケツそのものとの親子関係はありません。


まずは取っ手を制御する UdonBehaviour を取り付けましょう。今回取っ手は同期させる必要がないので Synchronize Position は無効のままです。そして Udon C# Program Asset を生成し、スクリプトとして BucketHandle.cs を割り当てました。

とりあえず制御ができることを確認する為、こんなコードを書いてみました。

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class BucketHandle : UdonSharpBehaviour
{
    private const float bounds = 135.803f;
    private float angle = bounds;
    private Vector3 axis = new Vector3(1,0,0);
    void Start() { }
    void Update() {
        angle = Mathf.Sin(Time.time) * bounds;
        transform.localRotation = Quaternion.AngleAxis(angle, axis);
    }
}

f:id:phi16_ind:20200422203500g:plain

このスクリプトは毎フレーム angle を計算し、それを回転に反映させます。「X軸で angle 度回す回転」が Quaternion.AngleAxis によって生成されています。バケツの形状から可能な移動領域が-135.803°~135.803°であることがわかったので、その範囲を適当に動くようにしてみました。

さて、これによって後は angleいい感じに制御してあげれば良いということがわかります。根本的に取っ手は物理法則に沿って動くので、まずは取っ手に掛かる力について考えてみましょう。

まず明らかに重力があります。が、重力が掛かる先は「取っ手オブジェクトの原点」ではなく「取っ手」そのものです。何故なら力は質量に働くからです。また、大きさがある場合にはその重心に力が掛かっていると考えれば概ね良いことが知られているので、力が掛かる対象は取っ手の中心と考える事ができます。つまりここです。

f:id:phi16_ind:20200422205359p:plain

この場所が気になるので参照できるようにします。行儀良くやるなら public 変数にして Transform を受け取って、Start() の段階で localPosition を保持しておくのが良いと思いますが、今回は直接 private Vector3 relativeCenter = new Vector3(0,0.19f,0); を保持させました。

f:id:phi16_ind:20200422210910p:plain

さて力学の話です。力が掛かっているとしても移動できる向きが制限されている場合、実際に作用できるのはその「移動可能な向き」に掛かる力のみです。この向きは上の画像のように取っ手におけるZ軸方向なので、これは計算するとわかります。

float mass = 0.1f; // 好きな質量 [kg]
Vector3 gravity = - Vector3.up * 9.8f * mass; // 9.8 [m/s^2] は一般的な重力加速度
Vector3 tangent = transform.TransformDirection(new Vector3(0,0,1));
float force = Vector3.Dot(gravity, tangent);

これで力の大きさが取れました。しかしこれは並進方向の力であって、回転方向の「力」であるトルクではありません。トルクの 定義 に従ってみるとこうなります。

float mass = 0.1f;
Vector3 gravity = - Vector3.up * 9.8f * mass;
Vector3 location = transform.TransformVector(relativeCenter);
Vector3 torque = Vector3.Cross(location, gravity); // N = r × F
Vector3 worldAxis = transform.TransformDirection(axis);
float torqueValue = Vector3.Dot(torque, worldAxis);

トルク自体はベクトル量なので、今回のように回転可能な方向についての情報を取り出したい場合は (多分) 同様にして射影を取れば良いです。ワールド空間における軸との内積によって求めている回転に関わるトルクの大きさがわかりました。これが求めている値です。

さらにここから角加速度を取り出すには慣性モーメントの情報が必要です。今回は mass * Mathf.Pow(relativeCenter.magnitude,2.0f) で計算できるので、これを使うことでやっと角加速度が計算できることになりました。

角加速度 d^2\theta/dt^2 を角度 \theta に反映させるには積分をする必要があります。が、厳密な積分は基本的にできないので数値解法を使うことになります。最も一般的で誰でも思いつきそうで実際使われているのが陽的オイラーで、\displaystyle \frac{dx}{dt} = \frac{\Delta x}{\Delta t} のことです。つまり x += v * Time.deltaTime のことです。今回は角加速度を与えるので角速度を保持して変化させていくようにすると、次のようなコードになります。

public class BucketHandle : UdonSharpBehaviour
{
    private const float bounds = 135.803f;
    private Vector3 relativeCenter = new Vector3(0,0.19f,0);
    private float angle = bounds;
    private Vector3 axis = new Vector3(1,0,0);
    private float velocity = 0.0f;
    void Start() { }
    void Update() {
        float mass = 0.1f;
        Vector3 gravity = - Vector3.up * 9.8f * mass;
        Vector3 location = transform.TransformVector(relativeCenter);
        Vector3 torque = Vector3.Cross(location, gravity);
        Vector3 worldAxis = transform.TransformDirection(axis);
        float I = mass * Mathf.Pow(relativeCenter.magnitude, 2.0f);
        float accelaration = Vector3.Dot(torque, worldAxis) / I;

        velocity += accelaration * Time.deltaTime;
        angle += velocity * Time.deltaTime;
        transform.localRotation = Quaternion.AngleAxis(angle, axis);
    }
}

これでもうほぼいい感じに動きます。バケツを通り抜けるのが気になるので加算後にでもちょっと付け足したりすればもっといい感じです。

if(angle < -bounds) {
    angle = -bounds;
    velocity *= -0.5f;
}
if(angle > bounds) {
    angle = bounds;
    velocity *= -0.5f;
}

ちなみに思ったよりも遅いとか速いとかあれば重力を弄っちゃったりすればいいと思います。正しさよりも良い見た目だと思うので。 というかそういうことを言うと別に torqueValueforce に比例するので適当な比例定数 (2000 くらい) を掛けてしまえばそれだけでも良いという説もあります。モデルが変化しなければ。


さて、この処理では明らかに回転しか反映しません。即ちどれだけ左右に振ったりしても取っ手は動かないということです。なんか違いますね。これはアバターがバケツを移動させたときの力が反映されていないからです。物体が移動したという結果を生むには力が加わったという原因が必要なはずですが、Pickupによる移動にはそんな要素は存在しません。しょうがないので「物体が移動したときは、その移動に対応する力が存在していた」と解釈することにします (もっと良い解釈は存在するかもしれません)。

ある加速度 a が時間差分 \Delta t で生む移動差分は \Delta x = a/2\times(\Delta t)^2 と表せます (速度 0 を仮定しました)。これを逆向きに使うことにして、「1フレーム前の取っ手の位置」を覚えておくことで現在の位置との変化によって力を与えてあげることにしました。

public class BucketHandle : UdonSharpBehaviour
{
    private const float bounds = 135.803f;
    private Vector3 relativeCenter = new Vector3(0,0.19f,0);
    private Vector3 center = Vector3.zero; // Previous Position
    private float angle = bounds;
    private Vector3 axis = new Vector3(1,0,0);
    private float velocity = 0.0f;
    void Start() {
        // Initialize
        center = transform.TransformPoint(relativeCenter);
    }
    void Update() {
        float mass = 0.1f;
        Vector3 gravity = - Vector3.up * 9.8f * mass;

        // Added Lines
        Vector3 nextCenter = transform.TransformPoint(relativeCenter);
        Vector3 difference = center - nextCenter;
        center = nextCenter;
        Vector3 movement = mass * 2.0f * difference / Mathf.Pow(Time.deltaTime, 2.0f);
        Vector3 force = gravity + movement;

        Vector3 location = transform.TransformVector(relativeCenter);
        Vector3 torque = Vector3.Cross(location, force);
        Vector3 worldAxis = transform.TransformDirection(axis);
        float I = mass * Mathf.Pow(relativeCenter.magnitude, 2.0f);
        float accelaration = Vector3.Dot(torque, worldAxis) / I;

        velocity += accelaration * Time.deltaTime;
        angle += velocity * Time.deltaTime;
        if(angle < -bounds) {
            angle = -bounds;
            velocity *= -0.5f;
        }
        if(angle > bounds) {
            angle = bounds;
            velocity *= -0.5f;
        }
        transform.localRotation = Quaternion.AngleAxis(angle, axis);
    }
}

f:id:phi16_ind:20200422232950g:plain

というわけでいい感じになりました。お疲れさまでした。


おまけ

私がもともとこの記事を書こうと思ったきっかけの私のソースコードはこれです。

public class BucketHandle : UdonSharpBehaviour
{
    private const float bounds = 135.803f;
    private Vector3 relativeCenter = new Vector3(0,0.19f,0);
    private Vector3 relativeEps = new Vector3(0,0.19f,0.01f);
    private Vector3 center = Vector3.zero;
    private float angle = bounds;
    private float velocity = 0.0f;
    void Start() {
        center = transform.TransformPoint(relativeCenter);
    }
    void Update() {
        Vector3 nextCenter = transform.TransformPoint(relativeCenter);
        Vector3 moveDirection = (center - nextCenter) * 0.04f / Time.deltaTime;
        moveDirection += - Vector3.up * Time.deltaTime * 2.0f;
        center = nextCenter;
        
        Vector3 tangent = (transform.TransformPoint(relativeEps) - nextCenter).normalized;
        velocity += Vector3.Dot(tangent, moveDirection) * 1000.0f;
        angle += velocity * Time.deltaTime;
        if(angle < -bounds) {
            angle = -bounds;
            velocity *= -0.5f;
        }
        if(angle > bounds) {
            angle = bounds;
            velocity *= -0.5f;
        }
        transform.localRotation = Quaternion.AngleAxis(angle, Vector3.right);
    }
}

トルクとか全然考えてませんでした。比例定数をぐりぐり弄ることでどうにかしようという魂胆です。移動方向を計算するのがわからなかったので微小差分を実際に作ったりしています。まぁなんか気軽に作っていいとは思います。調節が大変だと力学ベースで考えると便利なんだと思います。以上おまけでした。