Rigidbody を用いた移動でよく出る不具合と、FixedUpdate 改造による解決法の提案【Unity】

# はじめに
本記事は [Advent Calendar 2022 Unity]() の 11 日目の記事です。 昨日の記事は **@nkjzm** さんの [【C#】Enumから任意の文字列配列への変換を安全に行う【Unity】](https://qiita.com/nkjzm/items/cfa45ca106d7aa4bc74d)  です！
アドカレには様々な Unity 知見の記事が公開されていますので、カレンダーのほうも是非見てみてください！ ↓

https://qiita.com/advent-calendar/2022/unity

# 概要

この記事では物理演算（`RigidBody`）移動を実装する時に、遭遇し得る問題と解決方法を紹介します。
また、解決方法の UnityPackage 、サンプルプロジェクトも [Github](https://github.com/cji3bp62000/VariableFrameRatePhysicsSystem) で配布しております。

記事が少し長くなりますが、お付き合い頂ければ幸いです。

:::note
<font color=orange>※ 2023/03/15 更新：</font>
Unity 2022.2 から機能が追加されて、3D 物理演算の演算タイミング（`FixedUpdate` or `Update`）が選べるようになりました。
上記の機能を使うと、本記事で解決したい問題<font color="afafaf">（本記事前半で説明）</font>が解決できますので、2022.2 以降は Unity 機能のほうの使用をオススメします。

機能紹介はこちらの記事をご覧ください→ [3D 物理演算が Update で演算できるようになりました](https://qiita.com/tsukimi_neko/items/6bce0351780ac4e2d9bc)
:::

# この記事の関連知識
- `Transform`
- `Rigidbody`
- `FixedUpdate`、`Time.fixedDeltaTime`
- `PlayerLoopSystem`

# 解決したい課題：移動
Unity での移動は大まか２つの方法に分かれています。
それぞれ、Transform による移動と、物理（Rigidbody）による移動です。

単純なエフェクトや演出の場合は Transform での移動で大丈夫ですが、
Collision など物理的な挙動を要する場合、Rigidbody をアタッチして、`Rigidbody.MovePosition()` や `Rigidbody.velocity`で物体を操作したほうが破綻しにくいです。

下記の動画は「Rigidbody をアタッチしている時、Transform と Rigidbody での移動の比較」です。
動画の右側で示したように、Transform での移動は地形を貫通しやすく、とても不安定です。比べて、Rigidbody はとても安定な物理演算を行い、殆ど貫通しません。
　
<iframe width="960" height="480" src="https://www.youtube.com/embed/GezxlQ8Ukpg" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

　
しかし、Rigidbody を用いた移動にもデメリットがあります。
特に下記２つがゲーム体験に影響してしまいます。

1. **カメラのカクつき**
2. **Input が取れない場合がある**

次節で、それぞれの現象を詳しく説明します。

## デメリット１：カメラのカクつき

下の動画、特に左側の Rigidbody のほうをを御覧ください。
　
<iframe width="960" height="480" src="https://www.youtube.com/embed/hscxyirc-1I" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
　
よく見ると、動画の左側のほうは、カメラが微かにカクついて、とても気持ちの良いカメラワークとは言えません。比べて、右側の Transform のカメラワークは流暢で、ゲームに適したカメラワークです。

このカメラのカクつきは、物理演算で移動している物体をカメラで追従する時に、よく現れる現象です。移動速度が速ければ速いほど、カクつきが大きくなります。

<font color=#b0b0b0>※ Rigidbody.Interporlate を使用していません</font>

## デメリット２：Input の受け漏れ

Rigidbody は物理演算を使用していますので、関連処理は `FixedUpdate()` 関数で実行するのがオススメです。
しかし、`FixedUpdate()` で `Input` を取ろうとすると、時々何故か押したのに取れない不具合が発生します。

例えば、このようなコードで、キャラをジャンプさせようとします。
```C#
[SerializeField] private Rigidbody rigidbody;

private void FixedUpdate()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        var newVelocity = rigidbody.velocity;
        newVelocity.y = newVelocity + 5f;
        rigidbody.velocity = newVelocity;
    }
}
```

このコードを実装して、ゲームを再生すると、下記の gif の左側のように、
　「Jump Pressed」が表示されているのに、ジャンプしないことがあります。
<img width="600" alt="input-compare.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295675/a4a7b03e-b5fa-f978-0632-2d3c9f624cdd.gif">

厄介なのは、「絶対」取れないのではなく、「**時々**」取れないです。

毎回必ず発生する不具合なら、発見がしやすく、調査や修正もやりやすいのですが、
たまにしか発生しない不具合は検知できないまま、ゲームを公開することもよくあります。

---

次の節で、以上のようなデメリット・不具合が発生する原因を究明します。

# 根本原因

Rigidbody を用いた移動で上記不具合が発生する原因は、ズバリ

:::note warn
「`Update()` と `FixedUpdate()` の更新間隔が同じではない」
:::

だからです。

Unity は、物理演算の精度をあげるために、物理演算の更新と表示部分の更新を切り離して、
表示部分は可変幅、物理演算部分は固定幅の時間で演算しています。

例えば、よく見る 60fps のゲームでは、deltaTime は 0.016 秒、fixedDeltaTime は 0.02 秒に設定されています。
この数値を元に、フレームの関係を書き出すと、下の時間軸になります；
![update_vs_fixedupdate-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295675/9f7ffaa4-d577-e1d9-0cba-1219b2757a16.png)

図で分かるように、実行していくにつれ、`FixedUpdate` と `Update` が少しずつズレていきます。

ある時間点では、下のようなことも起こり得ます：
![update_vs_fixedupdate-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295675/8844d3cd-526b-5cca-2c16-c6ae112bce77.png)
なんと、1 `Update` のフレームの中で `FixedUpdate` が呼ばれていません。

つまり、そのフレームでは Rigidbody は更新出来ずに、移動もジャンプもしません。**毎フレームの移動が不安定がために、追従しているカメラから見た景色もカクつきます**。

また、もし偶々そのフレームでボタンを押しても、**そのフレームでは `FixedUpdate` が呼ばれていないため、Input の検知が出来ません**。
次のフレームになると FixedUpdate が呼ばれるかもしれませんが、その時はもう `GetKeyDown() == true` ではなくなっていました。

---

画面のカクつきに対して、`Rigidbody.Interpolate` を設定すれば、現象は多少軽減されますが、
`FixedUpdate` の中で Input が取れない問題は、簡単な方法が中々見つかりません。

:::note warn
上記の不具合の全ての原因は、「`Update` と `FixedUpdate` の更新間隔が同じではない」によるものです。
:::
　
<details><summary><font color=#b0b0b0>※ Update と FixedUpdate の図の正確さについて</summary>

図では、`FixedUpdate` と `Update` が並列で実行しているように見えますが、実際は先に FixedUpdate を実行すべきかどうかの判定をして、実行する場合は「FixedUpdate-> Update」の順番で、同じスレッドで実行しています。

---
</details>

# 課題を解決する技術、手法

不具合の原因は Update と FixedUpdate の更新間隔が同じではないなら、
解決方法は簡単です。
ズバリ、「<font color=#CC0000>FixedUpdate と Update の更新間隔を一致させる</font>」ことが出来れば、全てが解決できます。
これが、本記事が提案したい手法です。
次節からは手法の詳細を説明します。

> <font color=orange>※ 2023/03/15 更新：</font>
> * Unity 2022.2 から機能が追加されて、3D 物理演算の演算タイミング（`FixedUpdate` or `Update`）が選べるようになりました。
>上記の機能を使うと、本記事で解決したい問題<font color="b3b3b3">（本記事前半で説明）</font>が解決できますので、2022.2 以降は Unity 公式のほうの機能の使用をオススメします。
>実装理念、PlayerloopSystem の実装例の参考などを考慮して、次に紹介する手法を残しておきます。


## 更新間隔を一致させる手法の概要

FixedUpdate と Update の更新間隔を一致させる方法は、意外と簡単です。

物理演算の更新間隔の `Time.fixedDeltaTime` は代入が可能なプロパティですので、
毎フレーム `Time.fixedDeltaTime` を調整して、`Time.deltaTime` と一致させれば、`FixedUpdate` と `Update` の間隔が一緒になり、両者が同時に実行されるようになります。
<font color=#b0b0b0>（→物理演算は可変な時間幅で行うことになります）</font>

　
調整処理の実行にあたって、今回は Unity が公開している `PlayerLoopSystem` という API を活用します。

`PlayerLoopSystem` は、毎フレーム実行すべき関数を追加・削除・調整できる API です。（▶ [Script Reference](https://docs.unity3d.com/ja/2021.2/ScriptReference/LowLevel.PlayerLoop.html)）
例えば、もし物理演算がまったく必要ないゲームでしたら、PlayerLoopSystem をカスタマイズして、物理演算及び FixedUpdate の呼び出しを削除することが出来ます。

この API を利用して、`FixedUpdate` が実行されるタイミングの直前に、`Time.fixedDeltaTime` を調整する関数を挿入します。
図解は下記になります：
![PlayerLoopSystem.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295675/21885a95-828e-c757-d210-4d6064a15895.png)

簡単なコードは下記になります：
（より扱いやすいバージョンは github で配布しております、こちらは参考用です）

```C#
// マーカー struct
public struct UpdateFixedUpdateTimeMarker { }

public static class VariableFrameRatePhysicsSystem
{
    // ゲーム開始時（シーンロード前）自動実行して、Time.fixedDeltaTime を更新するシステムを作る
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    private static void SetVariableFrameRatePhysicsSystem()
    {
        // 現在の PlayerLoop を取得
        var rootPlayerLoopSystem = PlayerLoop.GetCurrentPlayerLoop();

        for (int i = 0; i < rootPlayerLoopSystem.subSystemList.Length; i++) {
            // FixedUpdate だったら
            if (subSystem.type == typeof(FixedUpdate)) {
                // Time.fixedDeltaTime を更新するシステムを新たに作る
                var updateFixedUpdateTimeSystem = new PlayerLoopSystem() {
                    type = typeof(UpdateFixedUpdateTimeMarker),
                    updateDelegate = UpdateFixedUpdateTime,
                };

                // 更新システムを FixedUpdate の直前に挿入
                var updateSubSystemList = new List<PlayerLoopSystem>(rootPlayerLoopSystem.subSystemList);
                updateSubSystemList.Insert(i, updateFixedUpdateTimeSystem);
                rootPlayerLoopSystem.subSystemList = updateSubSystemList.ToArray();

                break;
            }
        }
    }

    // Time.fixedDeltaTime を更新する処理
    private static void UpdateFixedUpdateTime
    {
        Time.fixedDeltaTime = Time.deltaTime;
    }
}
```

## 手法の効果

上の手法を実装すると、毎フレーム Time.fixedDeltaTime の値が Time.deltaTime と同値になり、
**`FixedUpdate` と `Update` のタイミングが一緒になります**。

`FixedUpdate` と `Update` のタイミングを一緒にすることで、毎フレームで必ず `FixedUpdate` が実行されるようになり、
移動が安定になったおかげで カメラのカクつきがなくなって、`Input` 受け漏れの問題もなくなります。

この状態では、例え FPS が不安定な状態でも、下図のように 毎フレーム同期しますので、両方同じ間隔で進められます：
![update_vs_fixedupdate_fixed.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295675/30274445-9abf-5b17-0e25-61e59f2ba9a7.png)

# 結果

本記事で紹介した手法を使用すると、「カメラのカクつき」と「FixedUpdate で Input が取れない」デメリットが解消され、
綺麗な画面のまま、物理演算を用いた移動ができるようになります。
　
<iframe width="560" height="315" src="https://www.youtube.com/embed/sQFxOuJZGKI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
　

# 配布

上記の手法を元に制作した unity package を、Github にて配布しております：

▶ [VariableFrameRatePhysicsSystem（Github）](https://github.com/cji3bp62000/VariableFrameRatePhysicsSystem/releases/)

同レポジトリには RigidBody・transform の移動比較と、Unity の手法と本記事の手法の比較用シーンがあります。
Transform、Rigidbody の移動のメリデメを自分で確認したい方は、是非ご覧ください。

また、使いやすいように、Runtime 時でも手法を切り替えられるようにしました。
設定できる手法は下記になります：
- `Fixed`：
  - Unity 本家の手法
- `Variable`：
  - 本記事で紹介した手法、fixedDeltaTime = deltaTime
- `VariableWithSubStep`：
  - Variable と同じく、fixedDeltaTime = deltaTime ですが、deltaTime が大きすぎた場合 物理演算の精度が落ちるので
その場合はデフォルトの fixedDeltaTime 設定値で分割して、複数回に渡って物理シミュレーションを行う
  - 例：デフォルト fixedDeltaTime の 20ms で、
    - deltaTime が <font color=#CC0000>16ms</font> の場合、1 フレームは <font color=#4444EE>16ms の合計 1 回 FixedUpdate を実行</font>
    - deltaTime が <font color=#CC0000>33ms</font> の場合、1 フレームは <font color=#4444EE>20 + 13 ms の合計 2 回 FixedUpdate を実行</font>

詳しい設定のやり方は、[レポジトリの ReadMe](https://github.com/cji3bp62000/VariableFrameRatePhysicsSystem#3-%E3%81%A4%E3%81%AE%E6%9B%B4%E6%96%B0%E6%89%8B%E6%B3%95) をご覧ください。
（設定できる項目は１項目のみですので、簡単です！）

# 結

この記事では `Rigidbody` を用いた移動方法のメリットとデメリットを紹介して、
`fixedDeltaTime` を調整することでデメリットの部分を回避する手法を紹介しました。

# 備考・Q&A

<details><summary>なぜ Unity は固定幅 fixedUpdate にしたのか？ 変動にすると精度が悪くならないか？</summary>

　
**Unity** は「シミュレーションの正確さと一貫性」を維持することを優先しています。つまり、同じ条件ではまったく同じ結果になってほしいです。（[Unity 解説](https://docs.unity3d.com/ja/2019.4/Manual/TimeFrameManagement.html)）

憶測ですが、Unity はゲーム以外、工業シミュレーションなどにも利用されていますので、
しかし、アクションゲームなど、影響する要素が多すぎるプログラムは、時間的・空間的正確性をそこまで求めていませんので、多少オーバーキルかと自分は思っています。

実際、同じくゲームエンジンの **Unreal Engine** は可変物理フレームレートを使用しています。これは、本記事相当な手法です。（むしろ、本記事が Unreal にインスパイアされています）

Unreal Engine は正確性よりも、物理演算とゲームの他部分との協調性を選択しています：[Unreal Engine 解説](https://docs.unrealengine.com/4.27/ja/InteractiveExperiences/Physics/Substepping/#:~:text=%E6%9C%80%E5%A4%A7%E6%95%B0%E3%81%A7%E3%81%99%E3%80%82-,%E6%8A%80%E8%A1%93%E7%9A%84%E3%81%AA%E8%A9%B3%E7%B4%B0,-UE4%20%E3%81%A7%E3%81%AF%E5%8F%AF%E5%A4%89)

---
</details>

# 次の Advent Calendar

明日の Unity アドカレは **@adoringonion さん**の、[Behavior Treeで実装する敵AI](https://zenn.dev/adoring_onion/articles/behaviortree-samples) です！