Unity で時間計測する際、Time.deltaTime を直接 float 型の変数に足し込んでいませんか? 実はこれ、注意が必要なんです!!
_playTimeSec += Time.deltaTime; // 注意が必要!!
プロであるはずの職業プログラマーもやらかすことのある本問題を、float 型 (=単精度浮動小数点型) に対する注意喚起もかねて解説していきます。
目次
- はじめに
- 事前準備 - Unity プロジェクト環境
- 実際のプレイ時間計測コードで現象を確認
- 原因:なぜプレイ時間が正しく測れなくなるのか
- 簡単な対策:_playTimeSec を float → double に変える
- 本質的な対策:必要な分解能を見積もりつつ上限値を導入
- 浮動小数点演算の「情報落ち」について
- おわりに
- 主な更新履歴
はじめに
Unity でゲーム開発を行っている方で、Time.deltaTime を使ったことの無い方は、まず居ないと思います。
本記事を読んでいる方には説明不要だと思いますが、前回のフレーム (=Update() 呼び出し) からの経過時間 [秒] を取得できる API です。
ゲームの「プレイ時間」のように、時間を計測する目的で使っている方も多いと思います。
Time.deltaTime が float 型であることから、入門記事でも以下のような方法が紹介されることが多いため、何も考えず「float 型の」変数に足し込んでいる方も多いのではないでしょうか?
_playTimeSec += Time.deltaTime;
実は、このやり方は、計測対象の時間が短かければ問題は起こりませんが、プレイ時間のように「長時間の計測」が必要なケースでは、正しく時間を測ることができなくなります。
本記事では、上記のようなコードを書いた場合に起こる現象と、原因、回避方法について、実際に Unity で動かすことのできる C# Script をもとに解説していきます。
事前準備 - Unity プロジェクト環境
本記事で紹介する C# Script は、TextMesh Pro を使用してテキストの描画を行っています。
日本語フォントは不要なので、別記事「Unity の文字列描画 (TextMesh Pro) を C# Script のみで行う方法」にある「TextMesh Pro 機能の有効化 (TMP Importer)」までの手順を実施することで、本記事の C# Script を実際に動かすことができる状態になります。
実際のプレイ時間計測コードで現象を確認
下記が、本記事で使用する「問題のあるコード」の全貌です。新規 C# Script に追加すべきコードを強調表示しています。
using UnityEngine; using TMPro; public class NewBehaviourScript : MonoBehaviour { TextMeshPro _playTimeText; float _playTimeSec; void Start() { Application.targetFrameRate = 60; // 60 FPS Camera.main.orthographicSize = 135; // 480x270 px (270 / 2 = 135) _playTimeText = new GameObject("プレイ時間").AddComponent<TextMeshPro>(); _playTimeText.fontSize = 480; // 48 px _playTimeText.alignment = TextAlignmentOptions.Center; _playTimeText.enableWordWrapping = false; _playTimeSec = 0; // ★プレイ時間の初期値 } void Update() { _playTimeText.text = string.Format("[Play Time]\n{0:00}:{1:00}:{2:00}", (int)_playTimeSec / 3600, // 時 (int)_playTimeSec / 60 % 60, // 分 (int)_playTimeSec % 60); // 秒 _playTimeSec += Time.deltaTime; } }
実行すると、プレイ時間が 00:00:00 から 1 秒毎にカウントアップされます。(この時点では問題が出ません)
ここで一旦実行を停止し、プレイ時間の初期値を 75 時間にセットしてみましょう。
_playTimeSec = 75 * 3600; // ★プレイ時間の初期値
再度実行すると、一応 75:00:00 から始まるようですが…
どう見ても「秒」の動きが 1 秒よりも早いです。バグってます。
では、もう一度実行を停止し、さらに 150 時間に進めてみると何が起こるでしょうか。
_playTimeSec = 150 * 3600; // ★プレイ時間の初期値
プレイ時間が 150:00:00 のまま固まってしまいました。
なので、長時間計測する場合、いずれは止まってしまうことが分かります。
原因:なぜプレイ時間が正しく測れなくなるのか
それは、float 型の有効桁数では、長時間 (数十時間以上) の計測に耐えられないからです。
では、float 型の場合、具体的に何桁まで有効なのでしょうか?
これも、今の Unity のプロジェクト環境で簡単に調べられるので、実際に以下のコードで調べてみましょう。
いずれも、小数点の位置が違うだけで、有効桁数の確認用の数字が「123456789」であることが、動作確認上のポイントです。
using UnityEngine; public class NewBehaviourScript : MonoBehaviour { void Start() { float time1 = 123456789.0f; Debug.Log(string.Format("time1 = {0:0.0000000000}", time1)); float time2 = 1234.56789f; Debug.Log(string.Format("time2 = {0:0.0000000000}", time2)); float time3 = 0.123456789f; Debug.Log(string.Format("time3 = {0:0.0000000000}", time3)); } void Update() { } }
Debug.Log() を使っているので、実行結果は Console タブ側に出ます。
いずれも、小数点の位置に関わらず、123456789 だったものが 123456800 に化けました。
つまり、float 型の有効桁数は、6~7 桁程度しかないのです!
より細かな float 型の仕様の説明
C# の float 型は、IEEE というアメリカの国際規格団体が定めた「単精度浮動小数点数」の規格にしたがっています。
上記 Wikipedia の記事を見てみると、以下のようなビット割り当て (合計 32 bit) になっていることが分かります。
- 符号部 : 1 bit
- 指数部 : 8 bit
- 仮数部 : 23 bit
浮動小数点に詳しくない方にはちんぷんかんぷんかもしれませんが、分かりやすく言うと、それぞれ以下のような情報が格納されています。
- 符号部 (ふごうぶ)
- + か - かの情報を、0 か 1 かで区別します。
- 今回のコード例では、time1, time2, time3 いずれも「+」です。
- 指数部 (しすうぶ)
- 小数点の位置です。
- 今回のコード例では、time1, time2, time3 は互いに小数点の位置が異なるため、指数部の中身 (8 bit) のみ、互いに異なっていることになります。
- 仮数部 (かすうぶ)
- 有効桁の情報です。
- 今回のコード例では、time1, time2, time3 いずれも「1234568」に相当する情報が、23 桁の 2 進数 (= 23 bit) として格納されていることになります。
float 型の仮数部の仕様から導き出される有効桁数
実際には「正規化」と呼ばれる一種の圧縮処理を行っているため、あくまでも「概算」になりますが、仮数部が 23 bit なので、有効桁数の「分解能」は 2^23 (2 の 23 乗) です。
2^23 = 8388608 ですから、この数字の桁数を数えてもわかるとおり、7 桁分の分解能しかありません。
もう少しだけ厳密に言うと、10 進数の場合は「常用対数」で実質的な桁数 (情報量としての桁数) を求められますので、log10(8388608) = 約 6.92 より、「微妙に 7 桁に届かない」という計算結果になります。
計測可能な時間限界の定量化
計測可能な時間の上限は、累計時間の分解能を、1 フレームあたりの時間の分解能で割ることで見積もることができます。
- 累計時間の分解能
- 足し込む先の変数が float 型なので、今回の場合は 2^23 = 8388608 です。
- 1 フレームあたりの時間の分解能
- Application.targetFrameRate に設定されているフレームレートが 60 [FPS] なので、60 です。
つまり、今回のケースでは、2^23 / 60 = 約 139810 [秒] = 約 39 [時間] となります。
実際には、今回の事例だと、人間の目で見て明らかに現象が発生しているように見えるのは概ね 70 時間を過ぎたあたりからですが、見積ベースだと 39 時間を超えたあたりで何かしら問題が起こっている (=時間計測の精度の低下が始まっている) と考えるべきでしょう。
なお、繰り返しになりますが、実際には浮動小数点の計算時に「正規化」等の処理が行われるため、仮数部の実質的な分解能は 2^23 よりも増加しますが、事故を避けるためにも「見積は厳しく行うこと」が鉄則ですから、安全のためにも 2^23 だと仮定して計算すべきです。
追加実験:フレームレートが 60 FPS から 120 FPS に上がった場合
上記の計算式から、例えばフレームレートが 2 倍に上がると、計測できる時間の限界も半分になることが予想できます。
感覚で考えてみても、1 / 60 = 約 0.017 秒ずつ変数に足していたものが、1 / 120 = 約 0.0083 秒という細かさになるため、必要な分解能が上がることは明白です。
実際に、最初に紹介したコードを、Application.targetFrameRate = 120 に変えて実験してみましょう。
60 FPS のときは、75 時間の時点では秒の進み方が早くなるだけでしたが、120 FPS に上がると、60 FPS 時の 150 時間のときと同じように、時間が止まってしまいました。
つまり、フレームレート次第では、float 型だとより早く限界が来る可能性があるので「危険」だということになります。
簡単な対策:_playTimeSec を float → double に変える
今回の減少の原因は、足し込む先の変数の有効桁数 (=仮数部) が足りないことにありますから、単純に仮数部のビット数を増やせば解決します。
つまり、最も手軽な方法 _playTimeSec を double 型 (倍精度浮動小数点数) に変えてしまえば良いのです。
上記の Wikipedia の記事を見てもわかるとおり、仮数部は 52 bit です。
フレームレートが 60 FPS であると仮定して、前述の方法で限界時間を見積もると、2^52 / 60 [秒] = 2^52 / 60 / 3600 [時間] = 約 208 億時間です。
さらに 24 と 365 で割ってあげると約 238 万年は問題ないという計算になりますので、本質的な解決法ではありませんが、実用的な回避策としては double 型に変えるだけでも十分有効だと言えます。
実際に、10 万時間程度なら問題がないことが分かります。
(むしろこれ以上桁数を増やすと、今度は int 型の最大値の「約 21 億」の方に引っかかってうまく動かなくなります)
本質的な対策:必要な分解能を見積もりつつ上限値を導入
今回のケースでは、前述の「簡単な対策」でも十分でしょう。
しかし、より本質的に対策するのであれば、「分解能」と「上限」の検討が必要です。
「分解能」の検討
これは既に実施済で、double 型であれば十分な精度が得られることは、ここまでの説明を読めばお分かりいただけるでしょう。
「上限」の検討
多くのゲームで、プレイ時間の上限は 99:59:59 であったり、999:59:59 であったりします。
これは、少なくともゲームソフトの世界においては、多くのユーザーにとって、プレイ時間は数百時間から数千時間計測できれば十分だからです。
余裕を見たとしても、とりあえず 9999 時間まで計測できれば、まずユーザーからクレームが来ることは無いでしょう。
また、プレイ時間が 9999:59:59 で止まったからと言っても、医療機器の組み込みソフトのバグのように人が死んだりする訳ではありませんから、「ゲームプログラムとしては」十分と思われる上限で時間のカウントを止めてしまっても問題はないのです。
参考 1 : 時間の上限に対する他の考え方
もう 1 つの設計として、実用ソフトに多い考え方ですが、「時間をループさせてしまう」という設計も、この世の中には多く見られます。
これは、例えば 99:59:59 になったら 00:00:00 に戻して、最初からカウントアップしなおす、というやり方です。
時間が 00:00:00 にリセットされても問題にならない分野であれば、このように「ループさせる」方法をもちいることで、原理上は永久的に問題が起こらなくなります。
このように、使用する用途に応じて、時間の扱い方を変える…という考え方も重要ですし、ゲームソフトの時間のロジックを検討する場合にも役に立ちますので、いろいろな考え方に慣れておくのは無駄にはならないと筆者は考えています。
実際に筆者も、ゲームプログラムにおいて、キャラクターのアニメーション用のタイマーには、この「リセット方式」を採用しています。
参考 2 : 本質的な対策ができない事例
もちろん、本質的な対策ができない事例もこの世の中にはあります。
その代表格が、「2000 年問題」や「2038 年問題」などです。
前者は年号を 2 桁に省略して表す習慣から生じた問題であり、後者は 32 bit の整数型の上限 (符号付きなら約 21 億) から来る問題です。
さすがに、このような用途には、情報を格納する先のサイズ (ビット数など) を増やすことしか対策する方法がありません。なぜならば、「西暦」という年号は、人類が滅ぶか、この世界によほど大きな政変などが起こらない限りリセットされることはなく、未来永劫、年数が増え続けると考えられるからです。
例えば、おそらく 8000 年後の人類は、西暦が無くならない限り、「10000 年問題」に対処しなければならなくなるでしょう…
話が大きくなりすぎてしまいましたが、筆者が言いたいことは、「時間」という単調増加し続けるパラメーターを扱うということは、それだけ考えなければならないことが多いということです。
浮動小数点演算の「情報落ち」について
実は、本記事で紹介した現象は、IT の専門用語で情報落ちと呼ばれています。
IT 系企業に就職したばかりのエンジニアの方や、情報系の学生の方が受験する機会の多い「基本情報技術者試験」でも、午前・午後ともに頻出の用語です。
- 情報落ち
- 絶対値の大きな数と、絶対値の小さな数を加減算したとき、絶対値の小さな数が計算結果に反映されないこと。
読んだだけではよく分からないかもしれませんが、以下のように置き換えて上記の文章を読んでみると、まさに本記事で紹介した現象と同じだということが分かると思います。
- 絶対値の大きな数
- プレイ時間 (例えば 150 時間 = 540000 秒)
- 絶対値の小さな数
- Time.deltaTime (例えば 60 FPS だと 1/60 秒 = 約 0.017 秒)
「540000 秒」という絶対値の大きな数に、「0.017 秒」という絶対値の小さな数を足すことになる訳ですから、float 型だと下のほうの桁が捨てられてしまう訳です。
ここを読んでいる方で、情報処理技術者試験の受験を考えている方は、これで「情報落ち」について実例とともに覚えておくことができるはずです。
おわりに
文書による詳細説明の分量も増えてしまったので、画像 (スクリーンショット) だけを見て流し読みされた方も多いと思いますが、「float 型の扱いには注意が必要」であることはお分かりいただけたと思います。
実際に、筆者のプログラマー / SE としての実務経験上も、本記事で紹介した float 型の「情報落ち」によるソフトウェア不具合は何度も目にしてきました。
逆を言うと、実務経験のあるプロですら、float 型の有効桁数に対する認識不足で不具合を作りこんでしまうことが多いことを意味します。
Unity の Time.deltaTime は、API 側が float 型であるため、本問題について分かりやすく紹介できると考えたのが、本記事の執筆のきっかけです。
繰り返しになりますが、情報落ちによる不具合は、ゲーム分野であるか否かを問わず、日々「開発現場」で量産され続けていますので、浮動小数点計算に対する警戒感を持った IT エンジニアの方が 1 人でも多く増えてくれることを祈っています。
主な更新履歴
- 2023-04-29
- 本記事で実際に動かすサンプルプログラムの動画を冒頭に追加。
- 2023-03-15
- 初版