【Unity(C#)】WaitForFixedUpdateを使って時間ぴったりの処理を行う

　　
　　
　　
この記事は

**『プログラミング完全未経験からUnityでの開発現場に迎え入れてもらえた世界一の幸せ者』**

の記事です。そのつもりでお読みください。



##はじめに
前回、前々回の記事をベースに話が進みます。
前回→[SteamVR Pluginを使わないVRでのフェード](https://qiita.com/OKsaiyowa/items/413c9da869f23f6b44d1)
前々回→[3Dのフェード](https://qiita.com/OKsaiyowa/items/474dc86173d74b49edfd)

別に読まなくても超初心者級のことしかしてないので大丈夫だと思います。

##コルーチンで時間指定したのにズレる

**コルーチンを使えば、指定した時間に呼び出せる！**
調べたら↑が出てきたので「なんて便利な機能なんだ！」と思って使いまくってました。
しかし、秒数指定をしたループ処理で、何百、何千回と呼び出したところ、
微妙にずれてました。

あまり時間を気にしない場合は構わないのですが、厳密に秒数指定したい場合に
どうすればいいのかわからなかったので調べました。

##コルーチンの仕組み
Unityの[Documentation](https://docs.unity3d.com/jp/530/Manual/ExecutionOrder.html)を参考に説明を進めます。


スクリプトライフサイクルフローチャートのページを見てください。

説明に不要なものを省いたものがこちらです。(図①)
![スクリプトライフサイクルフローチャートCoroutine.png](https://qiita-image-store.s3.amazonaws.com/0/244071/9176c8bf-70e9-6c6a-e636-6ceb93eb8ef2.png)

図①の矢印をたどると、
`Update`→`yield WaitForSeconds`となってます。

また、
`FixedUpdate`→`yield WaitForFixedUpdate`ともなっています。


実際にコードを見ながら、具体例を挙げて説明していきます。

##なぜズレたのか

前提条件として、FPSは90で安定しているものとします。
つまり、1秒÷90フレーム=0.011...となり、
**1フレームあたり0.011秒の時間が経過する**ということです。
(厳密に言うと違いますがそれは後程説明します)

↓[コルーチン](https://qiita.com/OKsaiyowa/items/d0f59ae0c97b29118a80)内で0.1秒ごとに処理が行われるよう指定したループがあります。

```C#
while (true)
        {
            yield return new WaitForSeconds(0.1f);
            //なんかいろいろな処理
        }
```

`yeild WaitForSeconds(指定秒数)`→`指定秒数を超えるまで待機`→`yeild WaitForSeconds以降の処理`

となるのですが、`指定秒数を超えるまで待機する`というのは全ての処理が止まるわけではありません。
全ての処理が止まってしまったら
**指定秒数を超えたことを誰に確認してもらえばいいんだ？**という問題に陥ります。

つまり、指定秒数を超えたことを確認するために動いている人がいます。
それが`Update`です。

しかし、`Update`は1フレームごとにしか呼ばれません。
毎フレーム0.1秒を超えたか確認しようと頑張ります。

1フレーム目:0.011→まだ超えていない
2フレーム目:0.022→まだ超えていない
3フレーム目:0.033→まだ超えていない
　　　　・
　　　　・
　　　　・
8フレーム目:0.088→まだ超えていない
9フレーム目:0.099→まだ超えていない
10フレーム目:**0.11 →超えた！**

**いや過ぎてますやん**
0.1秒後ではなく0.11秒後に超えたことを認識しています。
これが誤差の原因です。

たった1回の処理なら誤差は0.01秒ですが、これが100回、1000回となると
1秒、10秒と膨れ上がります。


##WaitForFixedUpdate
先程の例で鋭い人は
**「じゃあ0.011秒間隔で秒数指定すれば誤差0じゃん！」**
と思ったかもしれません。

ですが、**Updateは1フレームが等間隔で呼ばれ続けるものではありません**のでそれは無理です。
様々な処理に影響されるので安定してFPSをキープしたとしても、微妙にずれてしまいます。

そこで役に立つのが`FixedUpdate`です。
`FixedUpdate`は毎フレーム等間隔で呼ばれます。
そんな便利な`FixedUpdate`はコルーチンにも活かされています。

それが**WaitForFixedUpdate**です。
このように使います。

```C#
while (true)
        {
            yield return new WaitForFixedUpdate();
            //なんかいろいろな処理
        }
```


`FixedUpdate`のフレーム間隔が0.02であった場合、
誤差無く0.02秒間隔で呼ばれます。


##改善したコード
[前回の記事](https://qiita.com/OKsaiyowa/items/474dc86173d74b49edfd)をもとに
VRのフェードの実装をいい感じに頑張りました。

[サブカメラ](https://qiita.com/OKsaiyowa/items/413c9da869f23f6b44d1)の動的な生成から設定までStart内で行うようにしてます。

VRでなくても動くので本当に指定した秒数で呼ばれているか確認したい人は
コピペして動かしてみてください。

```C#
using System.Collections;
using UnityEngine;
using UnityEngine.UI;

public class UtilityFadeCanvas : Monobehavior
{
    Canvas faceCanvas;

    Image facePanel;

    Camera faceCamera;

    Coroutine coroutine;

    GameObject camera_G;

    [Tooltip("フェードする色")]
    public Color panelColor = new Color(0, 0, 0, 0);

    [Tooltip("フェードの時間")]
    public float fadeTime = 1;

    [Tooltip("0～1の値で透明度を設定")]
    public float alpha_Panel = 0.5f;

    const float FIXED_UPDATE_DELTATIME = 0.02f;

    float startTime;

    void Awake()
    {
        //シーンをロードするたびに新しいカメラを生成
        if (GameObject.Find("OnlyUIRenderingCamera"))
        {
            Destroy(GameObject.Find("OnlyUIRenderingCamera"));
        }
    }

    // Start is called before the first frame update
    void Start()
    {

        //カメラ自動生成
        camera_G = new GameObject("OnlyUIRenderingCamera");
        faceCamera = camera_G.AddComponent<Camera>();
        faceCamera.clearFlags = CameraClearFlags.Depth;
        faceCamera.cullingMask = (1 << LayerMask.NameToLayer("UI"));

        //キャンバス生成＆設定
        GameObject canvas_G = new GameObject("FaceCanvas");
        faceCanvas = canvas_G.AddComponent<Canvas>();
        canvas_G.AddComponent<CanvasRenderer>();


        //キャンバスのポジションを調整
        Vector3 canvasPosition = canvas_G.transform.position;
        canvasPosition.x = 0;
        canvasPosition.y = 0;
        canvasPosition.z = 0.1f;
        canvas_G.transform.localPosition = canvasPosition;

        //レンダリングをfaceCameraに
        faceCanvas.renderMode = RenderMode.ScreenSpaceCamera;
        faceCanvas.worldCamera = faceCamera;

        //パネル生成＆設定
        GameObject panel_G = new GameObject("FacePanel");
        facePanel = panel_G.AddComponent<Image>();

        Color tmpColor = facePanel.color;
        tmpColor.a = 0f;
        facePanel.color = tmpColor;

        //パネルをキャンバスの子に設定
        panel_G.transform.parent = canvas_G.transform;

        //パネルのポジションを正面に調整
        Vector3 panelPosition = panel_G.transform.localPosition;
        panelPosition.x = 0;
        panelPosition.y = 0;
        panelPosition.z = 0;
        panel_G.transform.localPosition = panelPosition;


        //キャンバスをカメラの子に設定
        canvas_G.transform.parent = faceCamera.transform;

        //Layerを変更
        canvas_G.layer = LayerMask.NameToLayer("UI");
        panel_G.layer = LayerMask.NameToLayer("UI");

    }

    void Update()
    {
        //Fixed Timestepを固定
        Time.fixedDeltaTime = FIXED_UPDATE_DELTATIME;

    }

    void FixedUpdate()
    {
        //キー押してない間はreturn
        if (Input.anyKey == false)
        {
            return;
        }

        if (coroutine == null)
        {
            //テスト用　フェードアウト
            if (Input.GetKeyDown(KeyCode.O) && panelColor.a == 0)
            {
                FadeOut();
            }

            if (Input.GetKeyDown(KeyCode.I) && panelColor.a == alpha_Panel)
            {
                FadeIn();
            }
        }
    }


    public  void FadeOut()
    {
        if (panelColor.a == 0)
        {
            //スタートの時間記録
            startTime = Time.time;
            print("フェードアウト開始");

            coroutine = StartCoroutine(FadeOutCoroutine());
        }
    }


    public  void FadeIn()
    {
        if (panelColor.a == alpha_Panel)
        {
            //スタートの時間記録
            startTime = Time.time;
            print("フェードイン停止");

            coroutine = StartCoroutine(FadeInCoroutine());
        }
    }

    IEnumerator FadeOutCoroutine()
    {
        int count = 0;

        yield return new WaitForFixedUpdate();
        while (facePanel.color.a < alpha_Panel - 0.00005f)
        {
            yield return new WaitForFixedUpdate();
            panelColor.a += alpha_Panel / (fadeTime * 50);
            facePanel.color = panelColor;

            count++;

            //フェード中の時間、Alphaを確認
            print(Time.time - startTime);
            print("アルファの値:" + panelColor.a + ":" + count + "回目");

        }
        print("フェードアウト停止");
        panelColor.a = alpha_Panel;

        StopCoroutine(coroutine);
        coroutine = null;
    }


    IEnumerator FadeInCoroutine()
    {
        int count = 0;

        yield return new WaitForFixedUpdate();
        while (panelColor.a > 0 + 0.00005f)
        {
            yield return new WaitForFixedUpdate();
            panelColor.a -= alpha_Panel / (fadeTime * 50);
            facePanel.color = panelColor;

            count++;

            //フェード中の時間、Alphaを確認
            print(Time.time - startTime);
            print("アルファの値:" + panelColor.a + ":" + count + "回目");

        }
        print("フェードイン停止");
        panelColor.a = 0;

        StopCoroutine(coroutine);
        coroutine = null;
    }
}
```

##追加したコード

###FixedUpdateのフレーム間隔

```C#
const float FIXED_UPDATE_DELTATIME = 0.02f;

void Update()
    {
        //Fixed Timestepを固定
        Time.fixedDeltaTime = FIXED_UPDATE_DELTATIME;

    }

```

もちろんEditorで設定することもできます。
`Project　Settings`→`Time`→`Fixed Timestep`の項目です。
![ProjectSettingTime.png](https://qiita-image-store.s3.amazonaws.com/0/244071/44b84d04-1aba-fbb8-f793-35615105dfcd.png)


###シーンのロード時の処理

Start内でカメラ、キャンバス、パネルを生成しているので
シーンを破棄して読み込むたびに2つ、3つと増えてしまっていました。

なのでロードの度に古いカメラ、キャンバス、パネルを消す処理を加えました。

カメラの子にキャンバス、パネルを設定しているので
カメラを消せば全て消えます。

```C#
void Awake()
    {
        //シーンをロードするたびに新しいカメラを生成
        if (GameObject.Find("OnlyUIRenderingCamera"))
        {
            Destroy(GameObject.Find("OnlyUIRenderingCamera"));
        }
    }
```

###誤差のないループ処理

`WaitForFixedUpdate`を利用してフレームを固定することで
呼び出し時間による誤差は改善できましたが、
alphaを加算代入するたびに微妙にずれるという煩わしい現象に悩まされました。

恐らくこれ→[浮動小数点数型のズレ](https://qiita.com/OKsaiyowa/items/c2549f9ce0fb34c46c51#%E6%B5%AE%E5%8B%95%E5%B0%8F%E6%95%B0%E7%82%B9%E6%95%B0%E5%9E%8B%E3%81%AE%E3%82%BA%E3%83%AC)

なのでそのズレを想定してあらかじめ引いておくという
パワープレーで乗り切りました。

```C#
IEnumerator FadeOutCoroutine()
    {
        yield return new WaitForFixedUpdate();
        while (facePanel.color.a < alpha_Panel - 0.00005f)
        {
            yield return new WaitForFixedUpdate();
            panelColor.a += alpha_Panel / (fadeTime * 50);
            facePanel.color = panelColor;
        }
        panelColor.a = alpha_Panel;

        StopCoroutine(coroutine);
        coroutine = null;
    }
```

##最後に
私の記事にしては長くなりました。読んでくださった方ありがとうございます。

実際に動かすとこんな感じで実行回数と秒数が見れます。
![ぴったり５.png](https://qiita-image-store.s3.amazonaws.com/0/244071/1220f053-08b6-2a3d-c35a-373095870e7f.png)

何秒まで誤差がないか実験したところ、
180秒まで誤差0でした。(厳密に0なのか保証はできませんが)

これを使えば
"時間制限でだんだん視界が奪われる"
といったギミックとかにも活かせそうです。