💫

Unityのアニメーションウィンドウで、保存済みカーブデータを使って生産性を上げる拡張

2023/04/11に公開

概要

Unityエディターの「アニメーションカーブウィンドウ」では、標準機能のままプリセットされたカーブを使えます。
しかし「アニメーションウィンドウ」では、プリセットカーブを使えず、イージングを自力で調整する必要があります。

エンジニアである私個人としてはニーズはないのですが、アニメーター・グラフィックデザイナーも含めたチーム全体の生産性を鑑みるとニーズがあったので、「アニメーションウィンドウ内の任意の2地点の間をプリセットされたイージングカーブで補完する拡張機能」を開発しました。

困っていたこと

「アニメーションカーブウィンドウ」では、標準機能のままプリセットされたカーブを使える

Unity上のインスペクターでアニメーションカーブを編集できる機能があり、以下のようなウィンドウをみたことがあると思います。

また、↑ここをクリックすると、↓以下のようなアニメーションカーブウィンドウが表示され、デフォルトのプリセットカーブを選択できます。

プリセットカーブのセットとして、自作のプリセットカーブファイルを使える

Unityエディタの標準機能として、自前のプリセットカーブファイルを利用できます。
Unityプロジェクト内にカーブファイルを作成すれば、以下のように利用できます。

(参考)カーブファイルの例

「アニメーションウィンドウ」では、プリセットされたカーブを使えない

しかし、残念ながらアニメーションウィンドウ(以下)ではプリセットされたカーブを使えません 😥

チーム全体の生産性を鑑みて、プリセットされたカーブを↑この画面で使えるようにするエディタウィンドウを作成しましたので、本記事に概要を記します。

作ったもの

「アニメーションウィンドウ」で、プリセットされたカーブを使える拡張ウィンドウ

  • アニメーションウィンドウ内で、編集したいカーブの編集したい範囲を選択して、ウィンドウ内のプリセットカーブのボタンをクリックすることで、選択範囲のカーブが自動で置き換わります。
    • 選択範囲の最初のキーと最後のキーの時間・値を維持したまま、あいだのキーを置換しています。

ソース

  • ソースのすべてはお見せできませんが、ポイントを記しますので、同じようなことをされる場合は参考にしてください。

まず、UnityEditorのアニメーションウィンドウの仕様は、以下のUnityCsReferenceで調べられます。
このあたりを調べながら、リフレクションを使って開発していきます。

1. カーブプリセットライブラリーのファイルを読み込む

以下のクラスをリフレクション目的でラップしたクラスのインスタンスを生成し、ScriptableObjectとして読み込ませます。
https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/PresetLibraries/CurvePresetLibrary.cs

    var scriptableObject = AssetDatabase.LoadAssetAtPath<ScriptableObject>("path to your curve preset library");
    var curvePresetLibrary = new CurvePresetLibrary(scriptableObject);
    var count = curvePresetLibrary.Count();
    _curvesMap.Clear();
    for (var i = 0; i < count; i++) {
      _curvesMap[curvePresetLibrary.GetName(i)] = curvePresetLibrary.GetPreset(i);
    }

2. 拡張ウィンドウのOnGUIを実装

ポイントとしては、EditorGUIUtility.DrawCurveSwatchで簡単にカーブをプレビューするRectが出力できることです。


  void OnGUI()
  {
    var y = 0;
    var previewHeight = 50;
    var merginY = 5;
    var contentsWidth = position.width - 20;

    var curveFile = (ScriptableObject)EditorGUILayout.ObjectField("カーブファイル", _curveFile, CurvePresetLibraryType, false, new[] { GUILayout.Width(contentsWidth) });
    if (curveFile == null) {
      _curveFile = null;
      EditorGUILayout.HelpBox("カーブファイルを選択してください。", MessageType.Info);
      return;
    }

    if (_curveFile != curveFile) {
      _curveFile = curveFile;
      LoadCurvesMap(_curveFile);
      if (_curvesMap.Count > 0) {
        CurveFilePathPrefs.Value = AssetDatabase.GetAssetPath(_curveFile);
      }
    }

    // space
    y += 30;

    // スクロール開始
    var scrollViewRect = new Rect(0, y, position.width, position.height);
    var scrollViewAllRect = new Rect(0, y, contentsWidth, (previewHeight + merginY) * _curvesMap.Count + 50);
    _scrollViewVector = GUI.BeginScrollView(scrollViewRect, _scrollViewVector, scrollViewAllRect);

    var previewWidth = contentsWidth / 2;
    foreach (var (curveName, curve) in _curvesMap) {
      EditorGUIUtility.DrawCurveSwatch(new Rect(0, y, previewWidth, previewHeight), curve, null, new Color(0.8f, 0.8f, 0.8f, 1f), new Color(0.337f, 0.337f, 0.337f, 1f));
      if (GUI.Button(new Rect(previewWidth + 10, y, 100, 20), curveName)) {
        ApplyCurve(curve.keys);
      }

      y += previewHeight + merginY;
    }

    GUI.EndScrollView();
  }

3. カーブデータを反映

いくつかリフレクション目的でラップしたクラスを使っていてこのままでは動きませんし、バリデーションや一部の処理は割愛していますが、処理の流れ的には以下のとおりです。
※ 連続して編集するために処理後にアニメーションカーブの選択範囲を修正する必要があるのですが、そのあたりのコードも本質と離れているので割愛しています。


  void ApplyCurve(Keyframe[] keyframes)
  {
    var animEditor = GetAnimEditor();

    if (animEditor == null) return;
    var curveEditor = animEditor.CurveEditor;
    var targetCurveGroups = curveEditor.SelectedCurves
      .GroupBy(x => x.CurveId)
      .ToDictionary(g1 => curveEditor.GetCurve(g1.Key), g2 => g2.ToArray());

    foreach (var (targetCurve, selections) in targetCurveGroups) {

      var curveMinTime = float.MaxValue;
      var curveMaxTime = float.MinValue;
      for (var i = 0; i < selections.Length; i++) {
        var key = curveEditor.GetKeyframe(selections[i]);
        curveMinTime = math.min(curveMinTime, key.time);
        curveMaxTime = math.max(curveMaxTime, key.time);
      }

      Keyframe? fromKey = null;
      Keyframe? toKey = null;
      var indexOffset = selections[0].Key;
      var keys = targetCurve.Curve.keys;
      for (var i = 0; i < keys.Length; i++) {
        var key = keys[i];
        if (curveMinTime <= key.time && key.time <= curveMaxTime) {
          // indexOffset : 削除後のindexなのでindex固定で数だけ合わせる
          targetCurve.RemoveKey(indexOffset);
          // 確保
          fromKey ??= key;
          toKey = key;
        }
      }

      // 2周目のためにkeyFramesを保全
      var newKeyFrames = new Keyframe[keyframes.Length];
      for (var i = 0; i < keyframes.Length; i++) {
        System.Diagnostics.Debug.Assert(fromKey != null, nameof(fromKey) + " != null");
        newKeyFrames[i] = new Keyframe(
          // 正規化されている値を実値に変更
          math.lerp(fromKey.Value.time, toKey.Value.time, keyframes[i].time),
          math.lerp(fromKey.Value.value, toKey.Value.value, keyframes[i].value),
          keyframes[i].inTangent,
          keyframes[i].outTangent,
          keyframes[i].inWeight,
          keyframes[i].outWeight
        );
        if (i == 0) {
          // 先頭
          AnimationUtil.SetKeyBroken(ref newKeyFrames[i], true);
          newKeyFrames[i].inTangent = fromKey.Value.inTangent;
          newKeyFrames[i].inWeight = fromKey.Value.inWeight;
        }
        else if (i == newKeyFrames.Length - 1) {
          // 最後
          AnimationUtil.SetKeyBroken(ref newKeyFrames[i], true);
          newKeyFrames[i].outTangent = toKey.Value.outTangent;
          newKeyFrames[i].outWeight = toKey.Value.outWeight;
        }
        else {
          AnimationUtil.SetKeyLeftTangentMode(ref newKeyFrames[i], AnimationUtil.GetKeyLeftTangentMode(keyframes[i]));
          AnimationUtil.SetKeyLeftWeightedMode(ref newKeyFrames[i], AnimationUtil.GetKeyLeftWeighted(keyframes[i]));
          AnimationUtil.SetKeyRightTangentMode(ref newKeyFrames[i], AnimationUtil.GetKeyRightTangentMode(keyframes[i]));
          AnimationUtil.SetKeyRightWeightedMode(ref newKeyFrames[i], AnimationUtil.GetKeyRightWeighted(keyframes[i]));
        }

        targetCurve.AddKey(newKeyFrames[i]);
      }

      // inSlopeとoutSlopeを再計算する必要がある
      // see : https://github.com/Unity-Technologies/UnityCsReference/blob/a2bdfe9b3c4cd4476f44bf52f848063bfaf7b6b9/Editor/Mono/Animation/AnimationWindow/CurveMenuManager.cs#L221-L251
      for (var i = 0; i < newKeyFrames.Length; i++) {
        AnimationUtil.UpdateTangentsFromModeSurrounding(targetCurve.Curve, indexOffset + i);
      }

      targetCurve.Changed = true;
    }

    animEditor.SaveChangedCurvesFromCurveEditor();
    animEditor.OwnerWindow.Repaint();

    Debug.Log("カーブを設定しました");
  }

対応するにあたり、UnityのAnimationCurveデータの構造や編集方法に詳しくなりました。
AnimationUtilの中身など遍く公開できず恐れ入りますが、同じようなことを考えていらっしゃる方の参考になりましたら幸いです

Happy Elements

Discussion