HoloLens 2で視線情報を取得してCSVファイルに出力する

こんにちは Iwaken Lab.の[きょうすけ](https://twitter.com/kyo_chan1357)です！
普段は[ワイクウーデザイン](https://ykuw-design.co.jp/)にてXRエンジニアやってます

本記事は、[Iwaken Lab.アドベントカレンダー2023](https://qiita.com/advent-calendar/2023/iwakenlab)の5日目の記事です
昨日の[サックーさんの記事](https://qiita.com/Kazu_Sack/items/32092a324240d97dae41)をご覧になってない方はそちらもぜひ

## 概要
「HoloLens 2のローカルストレージにCSVファイルを作成して書き込んで保存する」という、一見大したことなさそうな処理が一癖二癖あったのでその話をしようと思います
具体的には以下の話
 - Dispose()/Close()
 - UWPのライフサイクル

## 環境
- Unity 2022.3.11f
- MRTK 以下画像参照
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/869eaad8-2af0-a88b-61e5-0ab06e24fd0a.png)


## 目標
この記事では以下を達成することを目標とします
- 視線をカーソルとして可視化する
- 視線の座標をCSVに書き込む
- CSVをPCに保存する

そこで今回は以下のシチュエーションで上記目標を実装します
- シーン上にボタンが配置されている
- ボタンは視線が合った状態でピンチするとクリックできる
- クリックしたときの視線の座標とタイムスタンプをCSVに書き込む
- 保存されたCSVをPCに保存する

:::note
MRアプリケーション開発のセットアップ手順は[公式トレーニング](https://learn.microsoft.com/ja-jp/training/paths/beginner-hololens-2-tutorials/)でも参考にしてください
ただし、2023年11月段階ではMRTK3だけは**インポートしない方がいいです**
:::

## 実装例
### アイトラッキングの有効化
今回は深く考えずデモシーンのプロファイルを使います
#### デモシーンの導入
[Mixed Reality Feature Tool](https://www.microsoft.com/en-us/download/details.aspx?id=102778)を使用し、Mixed Reality Toolkit Examplesをインポートします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/f3422978-9f0e-96be-6ddc-7e7aa4a2e4f0.png)

その後、Package Managerからアイトラッキングのデモシーン`Demo - EyeTracking`をインポートします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/54f49805-e5b0-3674-f460-14637c14035c.png)

#### プロファイルの設定
Hierarchyの`MixedRealityToolkit`を選択し、`EyeTrackingDemoConfigrationProfile`を選択します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/54410c69-25a4-f24d-c8db-1c786e55d2ea.png)

デモシーンでは視線オブジェクトが透明なものになっているので別のPrefabを設定します
`input`タブ内の`Pointers > Gaze Settings > Gaze Cursor Prefab`を`DefaultGazeCursor`に変更します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/25599979-21ca-3111-5a65-bdabb643232f.png)

### ボタンの配置
MRTK付属のボタンをお好みで選んで適当にシーンに配置してください
今回は`PressableButtonHoloLens2`を使います
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/0a663564-f0a6-7bcf-e852-8982ac0388dc.png)

### スクリプト
CSVに取得した視線情報を書き込む処理を記述しています
```csharp:WriteCsv.cs
using UnityEngine;
using System;
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.MixedReality.Toolkit;

public class WriteCsv : MonoBehaviour
{
    private StreamWriter _streamWriter;

    // ストリームを開く
    private void Start()
    {
        var address = Application.persistentDataPath + "/test.csv";
        Debug.Log(address);

        // ファイルが存在していなければ作成する
        if (!File.Exists(address))
        {
            _streamWriter = File.CreateText(address);
            _streamWriter.Flush();
            _streamWriter.Dispose();
        }
        
        _streamWriter = new StreamWriter(new FileStream(address, FileMode.Truncate, FileAccess.Write), Encoding.UTF8);

        string[] s1 = { "タイムスタンプ", "パネルX座標", "パネルY座標" };
        var s2 = string.Join(",", s1);
        _streamWriter.WriteLine(s2);
    }

    // ボタンを押したときにタイムスタンプと座標を書き込む
    public void Write()
    {   
        var timestamp = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss");
        var touchX = CoreServices.InputSystem.EyeGazeProvider.HitPosition.x;
        var touchY = CoreServices.InputSystem.EyeGazeProvider.HitPosition.y;
        var s2 = string.Join(",", timestamp, touchX.ToString(CultureInfo.CurrentCulture), touchY.ToString(CultureInfo.CurrentCulture));
        _streamWriter.WriteLine(s2);
    }

    // 終了処理
    // ストリームを閉じる
#if WINDOWS_UWP
    private void OnApplicationFocus(bool focus)
    {
        if (focus) return;
        _streamWriter.Flush();
        _streamWriter.Dispose();
        Windows.ApplicationModel.Core.CoreApplication.Exit();
    }
#else 
    private void OnApplicationQuit()
    {
        _streamWriter.Flush();
        _streamWriter.Dispose();
    }
#endif
}
```
#### 保存先
保存先は`Application.persistentDataPath + "/test.csv"`となっていますが、具体的にはデバイスポータルの`System\File explorer\User Folder\LocalAppDat\${カンパニー名}\${アプリ名}\LocalState`以下に保存されます

#### ストリームを閉じる処理
AndroidなどでCSVの書き込み処理をしたことある人は以下のようなコードを書いたことがあると思います
```csharp
private void Write()
{
    _streamWriter.WriteLine(str);
    _streamWriter.Close();
}
```
この例ではストリームを閉じるとき`Close()`を使っています
ですが、この`Close()`は.NETチームにとって忌み子なようで、UWPでは適応範囲外になっています(画像元:[リファレンス](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.streamwriter.close?view=net-7.0))
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/831443/aab2306e-fc32-8cad-34fa-fef431b0fdd1.png)
そのため、HoloLensでは`Dispose()`を使います

:::note
DisposeやCloseについての話は以下の記事をご覧ください
- [Disposeにまつわる余談](https://ufcpp.net/study/csharp/rm_disposable.html)
:::

#### ストリームを閉じるタイミング
今回はHoloLensのメニューからホームボタンを押したときにやります

ホームボタンを押すと白いウィンドウが表示され、アプリが終了したように見えるので`OnApplicationQuit()`が呼び出されているような気もしますが、実は呼ばれません
と、言うより**実は終了していません**

まずHoloLensで動作するUnityアプリはUWPアプリに乗っかって動作します
そして、ホームボタンを押すと[UWPのライフサイクル](https://learn.microsoft.com/ja-jp/windows/uwp/launch-resume/app-lifecycle)で言うところのSuspendedになります
この状態から閉じるボタンを押してアプリを終了させても`OnApplicationQuit()`は呼ばれません

そこでHoloLensでの終了処理はプレイヤーフォーカスの動向を検知する`OnApplicationFocus(bool)`を使用します
HoloLensで動作するUnityアプリは、ホームボタンを押すとフォーカスが失われ、`OnApplicationFocus(False)`が呼び出されます
これを利用してストリームの閉じる処理を行います
```csharp
    // 終了処理
    // ストリームを閉じる
#if WINDOWS_UWP
    private void OnApplicationFocus(bool focus)
    {
        if (focus) return;
        _streamWriter.Flush();
        _streamWriter.Dispose();
        Windows.ApplicationModel.Core.CoreApplication.Exit();
    }
#else 
    private void OnApplicationQuit()
    {
        _streamWriter.Flush();
        _streamWriter.Dispose();
    }
#endif
```
今回はストリームを閉じるついでに`Windows.ApplicationModel.Core.CoreApplication.Exit()`を呼び出し、UWPアプリごと終了させるようにしています

## まとめ
UWPは癖が強く、ちょっとUnity触ったことある程度なら余裕で躓きそうですね

## 参考
- [Unity における Windows Store App のアプリケーションライフサイクル](https://blog.xin9le.net/entry/2017/02/12/223142)
- [HoloLensアプリの終了処理](https://akihiro-document.azurewebsites.net/post/hololens_exitapp/)
- [HoloLens2アプリでホームボタンが押されたときにアプリを完全に終了させる](https://bluebirdofoz.hatenablog.com/entry/2021/12/07/005931)