ここらできっちり整理してみましょう。
#Input
要するにこのエントリは、タイミングについて語り尽くしたいわけです。
で、入力のタイミングとは?手元のキーボードでもマウスでもゲームパッドでも、人間が押し下げる行為にタイミングも何もありませんよね。好きなときに押し、好きなときに離す。
ひとつのボタンに着目すると、押されると値がtrueになる、というわけでこんなイメージです。(デバイスドライバなど下層の話は省略しています)
いっぽうで、一般的に更新フレームは周期が一定とは限りません。処理落ちは必ずあるし、そもそも同期を取っていない場合もありますよね。
なのでこのように、フレームの間隔にはバラつきがあります。
ここで、例えば Updateは1フレームに一度呼ばれるので、
こんなふうに、フレームの枠に1度ずつ呼ばれているわけです。
さて、ではInputの状態はどこで更新されているのか。そもそも、Inputの関数を呼ぶたびに入力装置の状態を確認しているはずはないのです。どこかのタイミングで状態を一括で取得しており、それはフレームに一度です。つまりフレームの頭で状態を取得するとすれば、
こんな感じかな。そう、自由に押したり離したりしていても結局はこの瞬間しか、エンジンは見ていないのですよ!ま、60fpsや30fpsの頻度で確認していたら十分でしょ、という話です。
わかりやすいように更新を示すバーを、押した状態を赤、離した状態を青に色分けしてみました。
さて、問題をややこしくするのは、ゲームにおいては
- 押した状態(それまでは関係なく、いま押し下げられているか)
- 押した瞬間(それまで押されていなかったが押された)
この二つを区別しないといけない、ということです。押した状態は取得した値をそのまま返せばいいのですが、押した瞬間というのはどうしたらわかるでしょう?これは「前のフレームで離してあったか」も条件に加えることで検出しています。
●(丸)の位置に注目してください。押した瞬間や離した瞬間は、直前の状態から変化している箇所になっていることを図から確認できるでしょうか。
つまり!押した瞬間、というのはフレームの周期に依存していて、例えばですが
ものすごーく処理落ちしてるときに押しても、すぐ離してたら「押した瞬間」は検出されない!
そんなこともあり得ます。上の図でも一箇所、押しているのに検出されなかった部分がありますね。まーそれは今回のテーマとは関係ありませんが、入力というのが万能ではないことを知っておくのが肝要です。
#FixedUpdate
さて、FixedUpdateですが、これはUpdateと別の周期で動作しています。Updateが秒間60回呼ばれているからといって、FixedUpdate が60回呼ばれているとは限りません。この周期は別途設定されているわけです。なので、ここで大事なのは、
FixedUpdateが呼ばれる回数は、Updateが1回呼ばれる度に0回だったり1回だったり2回だったりn回だったりする!
ということです。周期が異なるとはそういうことです。別スレッドで動いているわけではないのでね!
1回、2回、0回・・・これはヤバい。ということがわかるでしょうか。
ここです。つまりFixedUpdateの関数中では、押し下げ瞬間のInput.GetButtonDown()が2回連続でtrueを返すことがありえてしまう、わけです。1回しか押してないのに弾が2発出ちゃいますね。もちろん0回のこともあるので、そのときは「押しても反応しない」という、ゆゆしき問題が発生します。
とまあ、こんな理由で、FixedUpdateではInput.GetナントカDown を呼ぶな、という話になるわけです。
そしてまったく同じ理屈で、押している状態を検出する Input.GetButton などは、別にFixedUpdateで呼んでも問題は起きません。トラブルが起きるのは「瞬間を返す関数をFixedUpdateで呼ぶこと」に限定されます。
物理演算
物理演算というかAddForceの話をします。こいつは ForceMode の引数に応じてぜんぜん別の物理効果を発生させます。
AddForce(force, ForceMode.Force);
この ForceMode.Force は 外力 を意味します。(第2引数のForceMode.Force を省略した場合もこちらになる)
AddForce(force, ForceMode.Impulse);
この ForceMode.Impulse は 力積 を意味します。
物理シミュレーションについての基本的な理解として、
Δt がすごく小さいことを、物理シミュレーションを実装した人は切実に期待している
ということを覚えておくと良いです。Δtが小さいとはどういうことかというと、あらゆる効果は数フレームにまたがって欲しい、ということです。
しかし現実はそんなに甘くなく、Δtはとても大きい。1/60秒とか、やってらんないほどでかい値になってるねと。なので仕方なく、瞬間的な力(つまり力積)を表すために特殊な実装が施されたForceMode.Impulseが用意されています(実際にゲームにおいては極めて便利でもありますけど)。
すなわち、以下の2点を守る必要があります。
- AddForce(force, ForceMode.Force) つまり外力を「押した瞬間」で呼ぶべきではない(それが困るからImpulseが用意されている)
- AddForce(force, ForceMode.Impulse) つまり力積を「数フレームにまたがって」呼ぶべきではない(それをしない約束でImpulseが用意されている)
上記の理屈がわかりにくいと思われた方は、こう考えてみてください。運動方程式から考えると明らかですが、
外力Forceは速度への変換にΔtを使用しますが、 $ \Delta v=\frac{Force}{m} \Delta t$
力積Impulseは速度への変換にΔtを使用しません。 $ \Delta v=\frac{Impulse}{m} $
力積はΔtを織り込み済みなので、1フレームだけ発生させること(という物理世界では意味不明な出来事)に矛盾が生じないのです。
さあ、だんだん情報が揃ってきました。このパズルの最後のピースは、ご存知 FixedUpdateが物理シミュレーションのループと同期している という事実です。
このように、物理演算の1ステップにつき必ず一度、FixedUpdateが呼ばれる、と言い換えても良いでしょう。
つまり、そもそも数フレームにまたがることが期待されている 外力 AddForce(force, ForceMode.Force)は、FixedUpdateで呼ばれなければならない ということです。Updateで呼んでしまったら物理シミュレーションにとっては断続的になってしまいますから!
このように Updateで外力のAddForceを呼んでしまうと、物理シミュレーションから見れば、AddForceが断続的に処理されてしまいますね。図には書いていませんが0回のところもアウトです。
よーわからん、という人は、呼んだAddForceは次回の物理演算で処理されるということをイメージすると(上図の緑の線)、問題が明らかになると思います。
そしてここまで話してきた理屈により、 力積のAddForceをUpdateで呼ぶことにはなんら問題はありません。
つまり・・
まとめると、
Input.GetButtonDown などの押し下げ瞬間は Update で呼ぶ必要がある。瞬間なのでAddForceとしては力積が良い
外力の AddForce は FixedUpdate で呼ぶ必要がある。連続なので入力としては Input.GetButton などの押し下げ状態が良い
の2点が実装の指針となるでしょう。あえて具体的な例を挙げれば、移動はFixedUpdate、ジャンプはUpdate で実装するのがよいです(全体を理解せずここだけ切り取って覚えると失敗するので注意)。
ちなみに正しくない実装でどうなるかというと、先ほどの例のように弾が2発出てしまったり、 物理シミュレーションのΔtを変更したら動きが変わってしまったり します。例えばバレットタイムの実装で泣きをみるかもしれません。まあ逆に言えば、「とりあえず見た目で動いてれば問題ない」という要件の場合は、力積の話を理解する必要はないですね。
Unityが提供しているサンプルなんかでも具合のよろしくない実装が見られますが、まあ何が正しいかってのは要件次第ですからね。Unityの設計意図とかも知りませんけど、ともかく入力や物理演算の動作から演繹すると、厳密にはこうするのが正しい(抽象度が高い)だろうよ、という話でした。