【Unity】時間経過によるシェーダーの_Timeの精度低下を検証

# はじめに
経過時間の計算など、加算し続ける変数をfloat型にしてしまうと、結構すぐに意図しない挙動を引き起こすという話があります。
値が大きくなると分解能が下がるので、そこに小さな値を足しても情報落ちが発生してしまうのです。
詳しくはこちらの記事などを参考にしてください。

https://qiita.com/yuji_yasuhara/items/0f94f3b60b4525dd1e74

## シェーダーではどうなる？
スクリプトではdouble型にすれば良いですが、シェーダーの場合はそういうわけにはいきません。
Unityシェーダーの組み込み変数である`_Time`からはシーンロード時点からの経過時間が取得できますが、float型です。
同じシーンを長時間プレイしていると、この値はどうなるのでしょうか？
恐らくスクリプトのdouble型の変数からシェーダーに値を渡しているので、値が大きくなっても情報落ちにはならないと推測できます。
しかし、分解能は下がっていくので、だんだんと動きがカクカクしていくことが予想されます。

予想だけでは自信を持って語れないので、検証することにしました。

floatの分解能の計算にはこちらのサイトを活用しました。

https://tools.m-bsys.com/calculators/ieee754.php


# 検証内容
検証用シーンを2種類作りました。
このシーンを暫く実行し続け、どのような変化が生じるかを観察します。

## 1:時計パターン
経過時間の値を参照して時計の1秒タイマーの針を動かします。
- 左：シェーダーで `_Time.y` を利用して針を回す
- 中央：スクリプトで `Time.timeSinceLevelLoadAsDouble` を利用して針を回す
- 右：スクリプトで `Time.deltaTime`をfloat型の変数に加算し、 累積値を利用して針を回す

<iframe width="560" height="315" src="https://www.youtube.com/embed/WaAzY5DyWIs?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

今回の検証目的とは異なるのですが、 `Time.deltaTime` の累積も精度が低いという話があるので、併せて検証することにしました。

右下の数値はそれぞれ以下の値です。
- Elapsed Seconds (DeltaTime)
  - `Time.deltaTime` の累積(float型)
  - 右側の時計の針を動かすのに利用している値
- Elapsed Seconds (float)
  - `Time.timeSinceLevelLoad` の値
  - 左側の時計の針を動かすのに利用しているシェーダーの `_Time.y` と同じ値と思われる
- Elapsed Seconds
  - `Time.timeSinceLevelLoadAsDouble` の値
  - 中央の時計の針を動かすのに利用している値

## 2:UVスクロールパターン
`_Time` を利用し、シェーダーのみでUVスクロールを行います。
毎フレーム以下の量スクロールさせます。
- 左： `frac(_Time.y) * 1.5`
- 右： `frac(_Time.w) / 2`
<iframe width="560" height="315" src="https://www.youtube.com/embed/5PGqen6ZR50?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>


# 検証結果
## 1時間後：deltaTime累積時計が遅れだす
右側のdeltaTimeの累積で針を回している時計が遅れるようになりました。

<iframe width="560" height="315" src="https://www.youtube.com/embed/az-jb-g5Nr8?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

右下の `Elapsed Seconds (DeltaTime)` の値を見ると他の値より小さいです。
毎フレームdeltaTimeを加算するときに分解能以下の小さな値は切り捨てられる[^1]ことになりますが、その切り捨てられた分が積もった結果だと思われます。

[^1]:正確には最近接丸め。もっと正確には端数処理の方法は環境によって異なります。

累積ではなく、 `deltaTime` の値の分だけ時計の針を回すというパターンも試してみたところ、この場合は数時間動かしても遅れは生じませんでした。
このため `deltaTime` の精度が低いわけではなく、加算が遅れの要因となっていると考えられます。

## 3日経過後：deltaTime累積時計が倍速になる＆UVスクロールがカクつきだす
経過時間が262,144秒（約3日）に達すると、floatの分解能（1bitと対応する値）が0.016から0.031になります。
![分解能0.031_10進数→IEEE754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/71f98958-4452-8ec0-f0cf-c58a3f00c582.png) ![分解能0.031_IEEE754→10進数.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/f22c08f5-549f-0945-7ee7-e8635f8769b7.png)

### 時計パターン
このタイミングで右側のdeltaTimeの累積で針を回している時計の動きが倍速になりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/D4hSt8p9Fz0?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

想定外の挙動であったため調査したところ、丸め誤差によるものであることがわかりました。

https://qiita.com/masamin/items/c70c518eee47317c5db2

### UVスクロールパターン
UVスクロールの方にも変化が現れました。
検証開始時点のものと比べると、僅かに滑らかさが減少しました。
右側の方が`_Time.w`(経過時間*3)を参照しており値の増加スピードが速いので、影響が大きく出ています。
<iframe width="560" height="315" src="https://www.youtube.com/embed/nYX_CHPxpgY?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 6日経過後：deltaTime累積時計停止
経過時間が524,288秒（約6日）に達すると、floatの分解能が0.031から0.063になります。
![分解能0.063_10進数→IEEE754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/d981f505-81fc-41ea-beea-d55559cd6470.png) ![分解能0.063_IEEE754→10進数.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/8ab51da1-12e2-a3d7-2af0-7ff12694fde5.png)
`_Time.y` は `1秒 / 0.063 ≒ 15fps` となります。

### 時計パターン
右側のdeltaTimeの累積で針を回している時計が停止しました。
左側のシェーダーで針を回している方も滑らかさがなくなりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/2ud_HM4zS9c?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

右下の `Elapsed Seconds (DeltaTime)` も増えなくなっています。
分解能が下がったことにより、毎フレーム約0.016を加算しても丸め処理で切り捨てとなり、情報落ちが発生して累積が増えなくなったということです。
完全に停止しているわけではなく、フレームレートが下がったと思われるときに急に動きます。

### UVスクロールパターン
UVスクロールはカクカクしているのがはっきりとわかるようになりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/X2cNb4Vf4ks?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

10日経過するとここまでカクカクするようになりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/AdBlFu1YvTY?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 12日経過後：8fps
経過時間が1,048,576秒（約12日）に達すると、floatの分解能が0.063から0.125になります。
![分解能0.125_10進数→IEEE754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/42dfaacc-f29c-5d2d-5658-6f1a88bb1552.png) ![分解能0.125_IEEE754→10進数.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/e59da5b5-ec23-9322-1ea6-2920ace4a037.png)
`_Time.y` は `1秒 / 0.125 = 8fps` となります。

### 時計パターン
左側のシェーダーで針を回している時計は8fpsになったので、きれいに1/8ずつ回るようになりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/15_apoifYbk?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

### UVスクロールパターン
こちらはカクカクというよりガタガタになってきました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/kQvQtyR6XtA?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 24日経過後：4fps
経過時間が2,097,152秒（約24.3日）に達すると、floatの分解能が0.125から0.25になります。
![分解能0.25_10進数→IEEE754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/df601223-c074-9c79-8e30-92a674834574.png) ![分解能0.25_IEEE754→10進数.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1605307/81e1c046-b05f-8e0b-57e0-3d26f6607a54.png)
`_Time.y` は `1秒 / 0.25 = 4fps` となります。

4fpsになったので、左側のシェーダーで針を回している時計はきれいに1/4ずつ回るようになりました。
右下の `Elapsed Seconds (float)` の値も0.25ずつ増えているのがわかります。
<iframe width="560" height="315" src="https://www.youtube.com/embed/bEicLEr5GOg?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 38日経過後：UVスクロール停止
右側の `_Time.w` を参照している方はスクロールが停止しました。
左側の `_Time.y` を参照している方も1回の動きの幅が大きくなり、右上から左下にスクロールしていると認識するのが難しくなりました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/QWRBfAJy9DQ" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>


# おわりに
理論上一週間もしないうちに影響が出るはずと考えて検証を開始しましたが、deltaTimeの累積が倍増した以外は予想通りの結果となりました。
ただこうなると、シェーダーで `_Time` を参照してアニメーションさせるのはかなりリスクが高いです。
アニメーションの速度がゆっくりであれば影響を感じるまでの期間も長くなりますが、それでもいつかはカクつきだします。
時間制限がありシーンが切り替わるタイミングが決まっているシーン以外では、ユーザーが同じシーンを何日間も開いたままにしておく可能性を捨て切れません。
シェーダーのみでアニメーションできるとスクリプトと疎結合になって便利なのに、これが使えないというのは残念ですね。


# まとめ動画
記事の内容を動画にもまとめました。
（音声付きです）

## 時計パターン
<iframe width="560" height="315" src="https://www.youtube.com/embed/bixay5oXoRs?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## UVスクロールパターン
<iframe width="560" height="315" src="https://www.youtube.com/embed/vcvUnXlzRoA?rel=0" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>