はじめに
本記事は Advent Calendar 2022 Unity の 11 日目の記事です。 昨日の記事は @nkjzm さんの 【C#】Enumから任意の文字列配列への変換を安全に行う【Unity】 です!
アドカレには様々な Unity 知見の記事が公開されていますので、カレンダーのほうも是非見てみてください! ↓
概要
この記事では物理演算(RigidBody
)移動を実装する時に、遭遇し得る問題と解決方法を紹介します。
また、解決方法の UnityPackage 、サンプルプロジェクトも Github で配布しております。
記事が少し長くなりますが、お付き合い頂ければ幸いです。
※ 2023/03/15 更新:
Unity 2022.2 から機能が追加されて、3D 物理演算の演算タイミング(FixedUpdate
or Update
)が選べるようになりました。
上記の機能を使うと、本記事で解決したい問題(本記事前半で説明)が解決できますので、2022.2 以降は Unity 機能のほうの使用をオススメします。
機能紹介はこちらの記事をご覧ください→ 3D 物理演算が Update で演算できるようになりました
この記事の関連知識
Transform
Rigidbody
-
FixedUpdate
、Time.fixedDeltaTime
PlayerLoopSystem
解決したい課題:移動
Unity での移動は大まか2つの方法に分かれています。
それぞれ、Transform による移動と、物理(Rigidbody)による移動です。
単純なエフェクトや演出の場合は Transform での移動で大丈夫ですが、
Collision など物理的な挙動を要する場合、Rigidbody をアタッチして、Rigidbody.MovePosition()
や Rigidbody.velocity
で物体を操作したほうが破綻しにくいです。
下記の動画は「Rigidbody をアタッチしている時、Transform と Rigidbody での移動の比較」です。
動画の右側で示したように、Transform での移動は地形を貫通しやすく、とても不安定です。比べて、Rigidbody はとても安定な物理演算を行い、殆ど貫通しません。
しかし、Rigidbody を用いた移動にもデメリットがあります。
特に下記2つがゲーム体験に影響してしまいます。
- カメラのカクつき
- Input が取れない場合がある
次節で、それぞれの現象を詳しく説明します。
デメリット1:カメラのカクつき
下の動画、特に左側の Rigidbody のほうをを御覧ください。
このカメラのカクつきは、物理演算で移動している物体をカメラで追従する時に、よく現れる現象です。移動速度が速ければ速いほど、カクつきが大きくなります。
※ Rigidbody.Interporlate を使用していません
デメリット2:Input の受け漏れ
Rigidbody は物理演算を使用していますので、関連処理は FixedUpdate()
関数で実行するのがオススメです。
しかし、FixedUpdate()
で Input
を取ろうとすると、時々何故か押したのに取れない不具合が発生します。
例えば、このようなコードで、キャラをジャンプさせようとします。
[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」が表示されているのに、ジャンプしないことがあります。
厄介なのは、「絶対」取れないのではなく、「時々」取れないです。
毎回必ず発生する不具合なら、発見がしやすく、調査や修正もやりやすいのですが、
たまにしか発生しない不具合は検知できないまま、ゲームを公開することもよくあります。
次の節で、以上のようなデメリット・不具合が発生する原因を究明します。
根本原因
Rigidbody を用いた移動で上記不具合が発生する原因は、ズバリ
「Update()
と FixedUpdate()
の更新間隔が同じではない」
だからです。
Unity は、物理演算の精度をあげるために、物理演算の更新と表示部分の更新を切り離して、
表示部分は可変幅、物理演算部分は固定幅の時間で演算しています。
例えば、よく見る 60fps のゲームでは、deltaTime は 0.016 秒、fixedDeltaTime は 0.02 秒に設定されています。
この数値を元に、フレームの関係を書き出すと、下の時間軸になります;
図で分かるように、実行していくにつれ、FixedUpdate
と Update
が少しずつズレていきます。
ある時間点では、下のようなことも起こり得ます:
なんと、1 Update
のフレームの中で FixedUpdate
が呼ばれていません。
つまり、そのフレームでは Rigidbody は更新出来ずに、移動もジャンプもしません。毎フレームの移動が不安定がために、追従しているカメラから見た景色もカクつきます。
また、もし偶々そのフレームでボタンを押しても、そのフレームでは FixedUpdate
が呼ばれていないため、Input の検知が出来ません。
次のフレームになると FixedUpdate が呼ばれるかもしれませんが、その時はもう GetKeyDown() == true
ではなくなっていました。
画面のカクつきに対して、Rigidbody.Interpolate
を設定すれば、現象は多少軽減されますが、
FixedUpdate
の中で Input が取れない問題は、簡単な方法が中々見つかりません。
上記の不具合の全ての原因は、「Update
と FixedUpdate
の更新間隔が同じではない」によるものです。
※ Update と FixedUpdate の図の正確さについて
図では、FixedUpdate
と Update
が並列で実行しているように見えますが、実際は先に FixedUpdate を実行すべきかどうかの判定をして、実行する場合は「FixedUpdate-> Update」の順番で、同じスレッドで実行しています。
課題を解決する技術、手法
不具合の原因は Update と FixedUpdate の更新間隔が同じではないなら、
解決方法は簡単です。
ズバリ、「FixedUpdate と Update の更新間隔を一致させる」ことが出来れば、全てが解決できます。
これが、本記事が提案したい手法です。
次節からは手法の詳細を説明します。
※ 2023/03/15 更新:
- Unity 2022.2 から機能が追加されて、3D 物理演算の演算タイミング(
FixedUpdate
orUpdate
)が選べるようになりました。
上記の機能を使うと、本記事で解決したい問題(本記事前半で説明)が解決できますので、2022.2 以降は Unity 公式のほうの機能の使用をオススメします。
実装理念、PlayerloopSystem の実装例の参考などを考慮して、次に紹介する手法を残しておきます。
更新間隔を一致させる手法の概要
FixedUpdate と Update の更新間隔を一致させる方法は、意外と簡単です。
物理演算の更新間隔の Time.fixedDeltaTime
は代入が可能なプロパティですので、
毎フレーム Time.fixedDeltaTime
を調整して、Time.deltaTime
と一致させれば、FixedUpdate
と Update
の間隔が一緒になり、両者が同時に実行されるようになります。
(→物理演算は可変な時間幅で行うことになります)
調整処理の実行にあたって、今回は Unity が公開している PlayerLoopSystem
という API を活用します。
PlayerLoopSystem
は、毎フレーム実行すべき関数を追加・削除・調整できる API です。(▶ Script Reference)
例えば、もし物理演算がまったく必要ないゲームでしたら、PlayerLoopSystem をカスタマイズして、物理演算及び FixedUpdate の呼び出しを削除することが出来ます。
この API を利用して、FixedUpdate
が実行されるタイミングの直前に、Time.fixedDeltaTime
を調整する関数を挿入します。
図解は下記になります:
簡単なコードは下記になります:
(より扱いやすいバージョンは github で配布しております、こちらは参考用です)
// マーカー 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 が不安定な状態でも、下図のように 毎フレーム同期しますので、両方同じ間隔で進められます:
結果
本記事で紹介した手法を使用すると、「カメラのカクつき」と「FixedUpdate で Input が取れない」デメリットが解消され、
綺麗な画面のまま、物理演算を用いた移動ができるようになります。
配布
上記の手法を元に制作した unity package を、Github にて配布しております:
▶ VariableFrameRatePhysicsSystem(Github)
同レポジトリには RigidBody・transform の移動比較と、Unity の手法と本記事の手法の比較用シーンがあります。
Transform、Rigidbody の移動のメリデメを自分で確認したい方は、是非ご覧ください。
また、使いやすいように、Runtime 時でも手法を切り替えられるようにしました。
設定できる手法は下記になります:
-
Fixed
:- Unity 本家の手法
-
Variable
:- 本記事で紹介した手法、fixedDeltaTime = deltaTime
-
VariableWithSubStep
:- Variable と同じく、fixedDeltaTime = deltaTime ですが、deltaTime が大きすぎた場合 物理演算の精度が落ちるので
その場合はデフォルトの fixedDeltaTime 設定値で分割して、複数回に渡って物理シミュレーションを行う - 例:デフォルト fixedDeltaTime の 20ms で、
- deltaTime が 16ms の場合、1 フレームは 16ms の合計 1 回 FixedUpdate を実行
- deltaTime が 33ms の場合、1 フレームは 20 + 13 ms の合計 2 回 FixedUpdate を実行
- Variable と同じく、fixedDeltaTime = deltaTime ですが、deltaTime が大きすぎた場合 物理演算の精度が落ちるので
詳しい設定のやり方は、レポジトリの ReadMe をご覧ください。
(設定できる項目は1項目のみですので、簡単です!)
結
この記事では Rigidbody
を用いた移動方法のメリットとデメリットを紹介して、
fixedDeltaTime
を調整することでデメリットの部分を回避する手法を紹介しました。
備考・Q&A
なぜ Unity は固定幅 fixedUpdate にしたのか? 変動にすると精度が悪くならないか?
Unity は「シミュレーションの正確さと一貫性」を維持することを優先しています。つまり、同じ条件ではまったく同じ結果になってほしいです。(Unity 解説)
憶測ですが、Unity はゲーム以外、工業シミュレーションなどにも利用されていますので、
しかし、アクションゲームなど、影響する要素が多すぎるプログラムは、時間的・空間的正確性をそこまで求めていませんので、多少オーバーキルかと自分は思っています。
実際、同じくゲームエンジンの Unreal Engine は可変物理フレームレートを使用しています。これは、本記事相当な手法です。(むしろ、本記事が Unreal にインスパイアされています)
Unreal Engine は正確性よりも、物理演算とゲームの他部分との協調性を選択しています:Unreal Engine 解説
次の Advent Calendar
明日の Unity アドカレは @adoringonion さんの、Behavior Treeで実装する敵AI です!