e.blog

主にUnity/UE周りのことについてまとめていきます

UnityでPivotを指定して同一スケーリングを行うスクリプト

https://i.gyazo.com/50ceb29458b165477af349d93fc7fcc5.gif

概要

Unityでは標準ではスケールのPivot位置を変更することができません。
とはいえそうした要望はきっと多いと思います。
実際にググって見ると、モデリングツールで変更しろ、だとか、Empty Objectを作って子にしてからスケーリングしろ、だとか、色々な情報が出てきます。

が、やはりスクリプトからPivot位置を指定してスケールを掛けたい場合もきっとあると思います。
そのために階層構造をいじるのはなんか違う気がしますし、なにより気持ち悪いです。

ということで、スクリプトからそれを実現できないかと思い、色々といじってみてうまく行ったのでそれを書きとどめたいと思います。

※ ただし、今回の例ではスケールに利用する値はすべて同じ値を想定しています。そうでない場合はSkewの影響でx,y,zの3要素で取得できないため別の処理が必要になります。

どのように動くかは冒頭のアニメーションGifを見てみてください。
ちょっと分かりづらいかもしれませんが赤いGizmoがPivotです。

コード

それほど長くないので、まずはコード全文を載せます。
コードは以下のような感じで実装しました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public static class PivotScaling
{
    /// <summary>
    /// ターゲットのTransformを、Pivotを基点にスケーリングする
    /// </summary>
    /// <param name="target">対象Trasnform</param>
    /// <param name="pivot">スケーリング基点</param>
    /// <param name="scale">スケーリング値</param>
    public static void Scale(Transform target, Vector3 pivot, float scale)
    {
        // 指定スケールになるように元のスケールを求める
        Vector3 originalScale = GetScale(target.localToWorldMatrix);

        // 親のマトリクス(Pivot用)
        Matrix4x4 parentMat = Matrix4x4.identity;
        parentMat[0, 0] = scale / originalScale.x;
        parentMat[1, 1] = scale / originalScale.x;
        parentMat[2, 2] = scale / originalScale.x;

        parentMat[0, 3] = pivot.x;
        parentMat[1, 3] = pivot.y;
        parentMat[2, 3] = pivot.z;

        // 自身のマトリクス
        Matrix4x4 mat = target.localToWorldMatrix;

        // 親の原点からの相対位置に移動させる
        Matrix4x4 transMat = Matrix4x4.identity;
        transMat[0, 3] = -pivot.x;
        transMat[1, 3] = -pivot.y;
        transMat[2, 3] = -pivot.z;

        Matrix4x4 result = parentMat * transMat * mat;

        target.position = GetPosition(result);
        target.localScale = GetScale(result);
        target.rotation = GetRotation(result);
    }

    /// <summary>
    /// マトリクスから位置を取り出す
    /// </summary>
    static Vector3 GetPosition(Matrix4x4 matrix)
    {
        return matrix.GetColumn(3);
    }

    /// <summary>
    /// マトリクスの回転行列からQuatenionを取り出す
    /// </summary>
    static Quaternion GetRotation(Matrix4x4 matrix)
    {
        return Quaternion.LookRotation(matrix.GetColumn(2), matrix.GetColumn(1));
    }

    /// <summary>
    /// マトリクスからスケールを取り出す
    /// 
    /// @warning ただし、uniform-scaleのみに対応
    /// </summary>
    static Vector3 GetScale(Matrix4x4 matrix)
    {
        float x = Mathf.Sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1] + matrix[0, 2] * matrix[0, 2]);
        float y = Mathf.Sqrt(matrix[1, 0] * matrix[1, 0] + matrix[1, 1] * matrix[1, 1] + matrix[1, 2] * matrix[1, 2]);
        float z = Mathf.Sqrt(matrix[2, 0] * matrix[2, 0] + matrix[2, 1] * matrix[2, 1] + matrix[2, 2] * matrix[2, 2]);
        return new Vector3(x, y, z);
    }
}

主な部分は最初のScaleメソッドだけです。
ここで行っていることは、手動でEmpty Objectを作って操作していることをコードでやっているに過ぎません。

大まかになにをしているかと言うと、以下のような手順で計算を行っています。

  1. 親のマトリクス相当の計算(スケールと平行移動)(*1)
  2. 対象Transformの座標を、親空間での位置に変換するための行列生成
  3. それらをかけ合わせて最終的な結果を取得

といった流れです。

最初のところでは、親となるEmpty Object相当のマトリクスを生成しています。
冒頭でも書いたように、同じ値のスケール(uniform scaling)以外の場合は結果が異なってしまうため想定していません。
(とはいえ、スケールの成分がそれぞれ異なるというケースはそこまで多くないと思います)

そのため、引数で受け取るのはfloatのスケール値です。
それを、現在のスケール値で割っているのは、最終的な計算結果のスケールが、指定されたスケールになるようにするためです。
(じゃないと、同じスケール値を指定してもどんどん拡大していってしまう)

parentMatのスケールを設定してるのは対角成分です。
スケールは以下のように対角部分に現れるので、それを直接書き込んでいるわけですね。
行列表現をすると以下のようになります。

\begin{vmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \end{vmatrix}

その後、親の平行移動成分を書き込んでいます。いわゆるPivot位置ですね。
平行移動成分は最後の行に格納されるため、[n, 3]要素に書き込んでいます。
このあたりは座標変換行列の基本ですね。

\begin{vmatrix} 0 & 0 & 0 & T_x \\ 0 & 0 & 0 & T_y \\ 0 & 0 & 0 & T_z \\ 0 & 0 & 0 & 1 \end{vmatrix}

※ 行列は、扱うシステムによってオーダーが異なります(行オーダー/列オーダーの2種類)。Unityの場合は上記のように「列」となります。

親の変換行列ができたら、次に生成している行列は、「親空間での座標位置への変換行列」です。
Empty Objectを生成して実行する場合は、Unityが自動的に座標変換してくれます。
実際にヒエラルキー状でドラッグ&ドロップすると、位置は変わらないものの、インスペクタの表示は変わりますね。
それを表現しているというわけです。

そして最後は、求めた各行列を、対象のTransformから得られた現在の行列に掛けてあげるだけです。

Matrix4x4 result = parentMat * transMat * mat;

これで求めたい行列が求まりました。

ただ、(なぜか)残念なことに、Transformに直接マトリクスを設定する方法がないため、行列から「位置」「回転」「スケール」を個別に取り出して設定しています。
それを行っているのが、サポート的に実装したGet****メソッドたちです。

サポートメソッド

それぞれのサポートメソッドが行っている処理は以下のようになっています。

GetPosition

行列から平行移動要素を取り出します。
といっても、設定と同じく3カラム目(0始まりなので4番目)の値をそのままベクトルとして抽出するだけです。

/// マトリクスから位置を取り出す
/// </summary>
static Vector3 GetPosition(Matrix4x4 matrix)
{
    return matrix.GetColumn(3);
}

平行移動要素はとてもシンプルですね。

GetRotation

続いて回転。
回転の取り出しは、行列から、オブジェクトの「ワールド空間での」各軸の方向が取り出せることを利用して、Quaternion.LookRotationを用いてクォータニオンを取得します。

/// <summary>
/// マトリクスの回転行列からQuatenionを取り出す
/// </summary>
static Quaternion GetRotation(Matrix4x4 matrix)
{
    return Quaternion.LookRotation(matrix.GetColumn(2), matrix.GetColumn(1));
}

matrix.GetColumn(2)で2番目のカラムを取り出していますが、これが、ワールド空間でのforwardのベクトル(非正規化)になります。
同様に、matrix.GetColumn(1)upベクトルに相当するため、このふたつを用いてクォータニオンを生成しているわけです。

GetScale

最後にスケール。
行列をある程度知っている人であれば、回転行列とスケールは掛け合わされて保存されていることを知っていると思います。
なので、値としてはとても複雑な値になってしまっています。
果たしてここから目的の値を取り出せるの? と思うかもしれませんが、可能です。

というのも、回転行列の値は、スケールが1であれば単位ベクトルを回転したものになっています。
裏を返せば、単位ベクトルになっていない=スケールがかかっている、というわけです。

と、ちょっと長く書きましたが、要は該当要素の「長さ」を計算してやることで、まさに「スケール」が求められる、というわけなのです。
実際のメソッドの中の処理は以下のようになります。

/// <summary>
/// マトリクスからスケールを取り出す
/// 
/// @warning ただし、uniform-scaleのみに対応
/// </summary>
static Vector3 GetScale(Matrix4x4 matrix)
{
    float x = Mathf.Sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1] + matrix[0, 2] * matrix[0, 2]);
    float y = Mathf.Sqrt(matrix[1, 0] * matrix[1, 0] + matrix[1, 1] * matrix[1, 1] + matrix[1, 2] * matrix[1, 2]);
    float z = Mathf.Sqrt(matrix[2, 0] * matrix[2, 0] + matrix[2, 1] * matrix[2, 1] + matrix[2, 2] * matrix[2, 2]);
    return new Vector3(x, y, z);
}

ただ、お気づきの方もいると思いますが、それぞれの要素の長さを計算しているわけですが、各要素が「個別に」スケールが書けられていた場合、不可逆な値になってしまっています。
これが、各スケールが同じでないとならない理由です。

しかしUnityにはlossyScaleというプロパティがあります。
これは、この回転とスケールの問題を「ある程度」予測して計算を行い、「それっぽい」値を返してくれるプロパティです。
(なので場合によっては誤差が出る)

いちおう計算する方法はあるようなのですが、かなり複雑なので今回は調べるのを断念しました。
そもそも今回の実装はuniformなスケールだけのケースで利用する目的だったため必要なかったのもあります。
とはいえ、uniformスケールであれば問題なく使えるので有用だと思います。