はじめに

ソニーの空間再現ディスプレイ ELF-SR2 がオフィスにやってきた!
早速Unityでなにか作ってみようと思います。

INDEX

空間再現ディスプレイって?

Spatial Reality Display について

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/AboutSRDisplay.html

空間再現ディスプレイ(Spatial Reality Display)とは

  • 立体的な空間映像を再現
  • 特別なメガネやヘッドセットなどを使わず裸眼で見ることができる
  • 高速ビジョンセンサーと視線認識技術により、見る人の目の位置を常に正しく検出
  • 目の位置情報をもとに、実際にディスプレイパネルから出す光源映像を3DCGデータからリアルタイムに生成

類似で Looking Grass がありますが、仕組みが異なります。
機器の正面にカメラセンサーが付いています。
このセンサーがユーザ位置を把握し続けて投影する映像をリアルタイムに生成しています。

開発環境の準備

開発者ポータル

空間再現ディスプレイ開発者向けサイト

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/

ドキュメント、Unity向けSDK、Tips などが掲載されています。

視聴環境

  • 普通に肉眼で顔が見えるぐらいの明るい空間
  • ELF-SR2から顔までの距離は近からず(50cm以上)遠からず(100cm以内)
  • 視野は前面130度ぐらい?
  • 真夏は注意(気温40度以下)

これらに気をつけて本機の設置場所を決めましょう。

推奨PC

  • Mac は非対応
  • GeForce RTX2070 Super以上を搭載
  • DLSS(Deep learning super sampling)を有効にする場合はRTX 40 シリーズ以上
  • 映像出力を USB-C 接続でするなら別途USB3.2 Gen2x2対応のUSB-Cを用意 (本機に付属していない)

ユニティちゃんライブステージ! with Spatial Reality Displayが配布されていて、推奨PCの基準はこのアプリが60FPSで動くことらしいです。

PCとELF-SR2の接続

Spatial Reality Display(SR Display)のセットアップ

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Setup/SetupSRDisplay.html

3通りの方法があります。

  1. HDMIケーブル
  2. DisplayPortケーブル
  3. USB Type-Cケーブル (USB3.2 Gen2x2対応)

1.と2.の場合は加えてUSB-C to USB-Aケーブルで接続が必要です
3.は USB3.2 Gen2x2対応 であることが必須です。

ディスプレイ設定

OSのシステム>ディスプレイ>拡大縮小レイアウトを開きます。

  • 拡大縮小: 100%
    • Unityで開発の場合はメインのディスプレイも含めて全て100%にする
  • ディスプレイの解像度: 3840×2160
  • 画面の向き: 横

専用アプリのインストール

SR Display Settingsのセットアップ

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Setup/SetupSRRuntime.html

SR Display Settings をDLしてインストールします。

UnityHubとUnityをインストール前に終了させること!!
(念を入れてタスクバーから裏で動いているUnity関連のタスクも終了させる)

SR Display Settingsの機能は次の通りです。

  • 機器と接続するドライバ
  • アプリを動かすランタイム
  • 機器の設定ツール

フェームウェウア

Firmware Updater

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Setup/FirmwareUpdater.html

本機付属の紙のマニュアルと開発者ページでフェームウェウアのアップデート手順が異なっていました。※2023/6 現在

こちらを見ると、どうやら旧型機(ELF-SR1)のファームウェアとその使い方について書いてあるみたいです。

Firmware Updater srd-display-fw-updater-1.04.00.03100.zip
本体ファームウェアのアップデータ(ELF-SR1用)

まだ本機のファームウェアの7アップデートは必要無いということでしょう。

開発ツール

Unity 2020.4, 2021.3, 2022.2をサポートしています。

Unityプロジェクトの構築

Unityのセットアップ

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/Setup.html
  1. 新規Unityプロジェクトを作成
  2. SR Display Plugin for Unity
    srdisplay-unity-plugin_vx.x.x.xxxx.unitypackage
    をダウンロード
  3. ダウンロードしたパッケージをプロジェクトにインポート

続けてプロジェクトの細かい設定をしていきます。

Build Settings

Architecture: Windows x86_64 (Intel 64-bit)

Player Settings

Scripting Backend: Mono

Quality Settings

V Sync Count: Don't Sync

これで下準備は終わりました!
次項より具体的にアプリを作っていこうと思います!!

どんなアプリを作る?

AI/ML STT×TTS×AR×ChatGPT なアプリをサクッと作る を移植します。

改修ポイント

  1. カメラ周りの入れ替え
    • ARFoundationをSRDisplayManager Prefabに入れ替える
  2. Speech To Text の入れ替え
    • Windows OS で動くものに改修する
  3. Text to Speech の入れ替え
    • Windows OS で動くものに改修する
  4. 会話モジュールを作り直す
    • 作り替えたSTT/TTSと連携させる
  5. シーンの再構築
    • 空間を認識する手がかりを作る
    • ディスプレイに収まるように配置
  6. UIとEventSystemの変更
    • UI Canvas を World Space に変更
    • Event System を入れ替え
  7. 検出した顔の位置を使ってみる
    • ロボガールがユーザのほうを向くようにしてみる
  8. ポストプロセス対応
    • SRDisplayManager Prefab配下のカメラに手を入れる
  9. 起承転結の制御
    • ボタンを押したら喋って、ボタンを離すとロボガールが喋るようにする

では改修を始めていきます。

カメラ周りの入れ替え

シーンヒエラルキーからAR SessionとXR Originを削除

代わりにSRDisplayManager Prefabを配置する

SRDisplayManager Prefab の構成はこちらに詳細が書かれていますが簡単に述べますとこうです。

  • WatcherAnchor: ユーザの 検出した頭の位置
  • LeftEyeAnchor: ユーザの 検出した左目の位置
  • RightEyeAnchor: ユーザの 検出した右目の位置

それぞれのAnchor配下にはカメラがあり、左右の目のカメラはレンダリング用、頭のカメラはレンダリング以外の別用途で使われます。

赤矢印のところの青と水色のGIZMOの線の意味はこうなります。

  • 青色: 処理上のビュースペース
  • 水色: ELF-SR2筐体の実際のディスプレイ面

Speech To Text の入れ替え

移植元のアプリはiOS/Android向けだったのでこちらを利用していました。

Speech And Text in Unity iOS and Unity Android

https://github.com/j1mmyto9/speech-and-text-unity-ios-android

当然Windows環境では使えないのでUnityEngine.Windows.SpeechDictationRecognizerを使用します。

また移植をしやすいようにこれまでの処理手順を流用できるようにSpeech And Text in Unity iOS and Unity AndroidにWindows対応を追加するようにしてみました。

全てのプラットフォームのベースとなるクラスを用意します。

    /// <summary>
    /// Speech To Text (ベース)
    /// </summary>
    /// <typeparam name="ClassT">継承先のクラス型</typeparam>
    /// <typeparam name="ParamsT">引き渡す設定情報</typeparam>
    public abstract class SpeechToTextBase<ClassT, ParamsT>: MonoBehaviour where ClassT : MonoBehaviour
    {
        /// <summary>
        /// ゲームオブジェクト名
        /// </summary>
        public static readonly string GAMEOBJECT_NAME = "SpeechToText";
                
        /// <summary>
        /// インスタンス
        /// </summary>
        protected static ClassT _instance;
        
        /// <summary>
        /// 結果確定時の実行処理
        /// </summary>
        public Action<string> onResultCallback;

        /// <summary>
        /// インスタンス
        /// </summary>
        public static ClassT Instance
        {
            get
            {
                if (_instance != null) return _instance;

                // モバイルのネイティブプラグインが UnitySendMessage で参照できるために GameObject であることが必要
                var go = new GameObject(GAMEOBJECT_NAME);
                _instance = go.AddComponent<ClassT>();
                return _instance;
            }
        }
        
        protected virtual void Awake()
        {
            _instance = this.gameObject.GetComponent<ClassT>();
        }
                
        /// <summary>
        /// パラメータ設定
        /// </summary>
        /// <param name="param"></param>
        /// <typeparam name="ParamsT"></typeparam>
        public abstract void Setting(ParamsT param);

        /// <summary>
        /// 聞き取りの開始
        /// </summary>
        /// <param name="message"></param>
        public abstract void StartRecording(string message);

        /// <summary>
        /// 聞き取りの終了
        /// </summary>
        public abstract void StopRecording();
                
        /// <summary>
        /// 設定完了時に呼ばれる
        /// </summary>
        /// <param name="message"></param>
        public virtual void onMessage(string message)
        {
        }
        
        /// <summary>
        /// プエラー発生時に呼ばれる
        /// </summary>
        /// <param name="message"></param>
        public virtual void onErrorMessage(string message)
        {
        }
        
        /// <summary>
        /// 結果確定時に呼ばれる
        /// </summary>
        /// <param name="results"></param>
        public virtual void onResults(string results)
        {
            if (onResultCallback != null)
                onResultCallback(results);
        }
    }

このベースクラスを継承したWindows用Speech To Textクラスを作成します。

    /// <summary>
    /// Speech To Text (Win)
    /// </summary>
    public class SpeechToTextForWin : SpeechToTextBase<SpeechToTextForWin, WinSTTParameters>
    {
        
        ~~~ 割愛 ~~~

        /// <summary>
        /// 音声認識装置の生成
        /// ※ Start() のライフサイクルタイミングで呼ぶこと
        /// </summary>
        public void CreateDictationRecognizer()
        {
            _dictationRecognizer = new DictationRecognizer();
        }
        
        /// <summary>
        /// パラメータ設定
        /// </summary>
        /// <param name="_language"></param>
        public override void Setting(WinSTTParameters param)
        {
            _dictationRecognizer.AutoSilenceTimeoutSeconds = param.AutoSilenceTimeoutSeconds;
            _dictationRecognizer.InitialSilenceTimeoutSeconds = param.InitialSilenceTimeoutSeconds;

            settingCallback();
        }

        /// <summary>
        /// 聞き取りの開始
        /// </summary>
        /// <param name="_message"></param>
        public override void StartRecording(string message)
        {
            _messageOfWinSpeech = null;
            _dictationRecognizer.Start();
        }

        /// <summary>
        /// 聞き取りの終了
        /// </summary>
        public override void StopRecording()
        {
            _dictationRecognizer?.Stop();
        }

       /// <summary>
        /// 設定完了時に呼ばれる
        /// </summary>
        /// <param name="message"></param>
        public override void onMessage(string message)
        {
            Debug.Log($"[Win/STT] {message}");
        }
        
        /// <summary>
        /// エラー発生時に呼ばれる
        /// </summary>
        /// <param name="message"></param>
        public override void onErrorMessage(string message)
        {
            Debug.Log($"[Win/STT] {message}");
        }
        
        /// <summary>
        /// 結果確定時に呼ばれる
        /// </summary>
        /// <param name="results"></param>
        public override void onResults(string results)
        {
            Debug.Log($"[Win/STT] {results}");
            
            base.onResults(results);
        }

        /// <summary>
        /// コールバック設定
        /// </summary>
        private void settingCallback()
        {
            _dictationRecognizer.DictationResult += dictationResult;
            _dictationRecognizer.DictationHypothesis += dictationHypothesis;
            _dictationRecognizer.DictationError += dictationError;
            _dictationRecognizer.DictationComplete += dictationComplete;
        }

        /// <summary>
        /// コールバック解放
        /// </summary>
        /// <param name="deep"></param>
        private void disposeCallback()
        {
            if(_dictationRecognizer == null)
                return;
            
            _dictationRecognizer.DictationResult -= dictationResult;
            _dictationRecognizer.DictationHypothesis -= dictationHypothesis;
            _dictationRecognizer.DictationError -= dictationError;
            _dictationRecognizer.DictationComplete -= dictationComplete;
        }
        
        /// <summary>
        /// フレーズ単位でテキストの取得に成功したとき
        /// </summary>
        /// <param name="text"></param>
        /// <param name="confidence"></param>
        private void dictationResult (string text, ConfidenceLevel confidence)
        {
            _messageOfWinSpeech += text;
            var mes = $"Dictation result: {text}, all: {_messageOfWinSpeech} confidence: {confidence}";
            onMessage(mes);
        }
         
        /// <summary>
        /// 語彙単位でテキストを取得に成功したとき
        /// </summary>
        /// <param name="text"></param>
        private void dictationHypothesis(string text)
        { 
            var mes = $"Dictation hypothesis: {text}";
            onMessage(mes);
        }
            
        /// <summary>
        /// 何らかのエラーが発生したとき
        /// </summary>
        /// <param name="error"></param>
        /// <param name="hresult"></param>
        private void dictationError(string error,int hresult)
        {
            var mes = $"Dictation error: {error}; HResult = {hresult}.";
            onErrorMessage(mes);
        }
            
        /// <summary>
        /// 文章単位でテキストの取得に成功したとき
        /// </summary>
        /// <param name="completionCause"></param>
        private void dictationComplete(DictationCompletionCause completionCause)
        {
                
            if (completionCause != DictationCompletionCause.Complete)
            {
                var mes = $"Dictation completed unsuccessfully: {completionCause}.";
                onErrorMessage(mes);
            }
            else
            {
                onResults(_messageOfWinSpeech);
            }
        }
    }

実装のポイントはこうでしょうか。

  • _dictationRecognizer へのコールバックの設定
  • DictationResultDictationCompleteのイベントをよく理解して実装

今回はDictationResultイベントで都度取得するフレーズを_messageOfWinSpeechに継ぎ足ししていき、dictationCompleteでベースクラスで定義しているonResultsを呼ぶようにしました。

Text to Speech の入れ替え

Text to SpeechもSpeech And Text in Unity iOS and Unity Androidを使っているのでWindows環境では使えません。

なので今回はVOICEVOXを使わせてもらいました。
またUnityで使いやすく作られたUnity VOICEVOX Bridgeも使わせてもらいました。

Unity VOICEVOX Bridgeのプロジェクトへの導入をこちらの通りに済ませます。

そうしたらSpeech To Text同様に全てのプラットフォームのベースとなるクラスを用意します。

    /// <summary>
    /// text To Speech (ベース)
    /// </summary>
    /// <typeparam name="ClassT">継承先のクラス型</typeparam>
    /// <typeparam name="ParamsT">引き渡す設定情報</typeparam>
    public abstract class TextToSpeechBase<ClassT, ParamsT>: MonoBehaviour where ClassT : MonoBehaviour
    {        
        /// <summary>
        /// ゲームオブジェクト名
        /// </summary>
        public static readonly string GAMEOBJECT_NAME = "TextToSpeech";
                
        /// <summary>
        /// インスタンス
        /// </summary>
        protected static ClassT _instance;
        
        /// <summary>
        /// 読み上げ開始コールバック
        /// </summary>
        public Action onStartCallBack;
        
        /// <summary>
        /// 読み上げ終了コールバック
        /// </summary>
        public Action onDoneCallback;
        
        /// <summary>
        /// 読み上げ中コールバック
        /// </summary>
        public Action<string> onSpeakRangeCallback;
                
        /// <summary>
        /// インスタンス
        /// </summary>
        public static ClassT Instance
        {
            get
            {
                if (_instance != null) return _instance;
                
                var go = new GameObject(GAMEOBJECT_NAME);
                _instance = go.AddComponent<ClassT>();
                return _instance;
            }
        }
                
        protected virtual void Awake()
        {
            _instance = this.gameObject.GetComponent<ClassT>();
        }
                
        /// <summary>
        /// パラメータ設定
        /// </summary>
        /// <param name="param"></param>
        /// <typeparam name="ParamsT"></typeparam>
        public abstract void Setting(ParamsT param);

        /// <summary>
        /// 発声の開始
        /// </summary>
        /// <param name="message"></param>
        public abstract void StartSpeak(string message);

        /// <summary>
        /// 発声の終了
        /// </summary>
        public abstract void StopSpeak();
        
        /// <summary>
        /// 読み上げ中の言葉を取得
        /// </summary>
        /// <param name="message"></param>
        public virtual void onSpeechRange(string message)
        {
            if (onSpeakRangeCallback != null && message != null)
            {
                onSpeakRangeCallback(message);
            }
        }
        
        /// <summary>
        /// 読み上げ開始
        /// </summary>
        /// <param name="message"></param>
        public virtual void onStart(string message)
        {
            if (onStartCallBack != null)
                onStartCallBack();
        }
        
        /// <summary>
        /// 読み上げ終了
        /// </summary>
        /// <param name="message"></param>
        public virtual void onDone(string message)
        {
            if (onDoneCallback != null)
                onDoneCallback();
        }
        
        /// <summary>
        /// エラー時
        /// </summary>
        /// <param name="message"></param>
        public virtual void onError(string message)
        {
        }
        
        /// <summary>
        /// 設定の成功時
        /// </summary>
        /// <param name="message"></param>
        public virtual void onMessage(string message)
        {
        }
    }

このベースクラスを継承したWindows用Text To Speechクラスを作成します。

    /// <summary>
    /// Text To Speech モジュール (Win)
    /// </summary>
    public class TextToSpeechForWin : TextToSpeechBase<TextToSpeechForWin, WinTTSParameters>
    {
        
        ~~~ 割愛 ~~~
        
        protected override void Awake()
        {
            base.Awake();
            
            //VOICEVOX生成
            var go = new GameObject("_VOICEVOX_");
            _voicevox = go.AddComponent<VOICEVOX>();
        }
        
        /// <summary>
        /// 設定
        /// </summary>
        /// <param name="param"></param>
        public override void Setting(WinTTSParameters param)
        {
            _voiceActor = param.Actor;
        }

        /// <summary>
        /// 発声の開始
        /// </summary>
        /// <param name="_message"></param>
        public override void StartSpeak(string message)
        {
            voicevoxPlay(message).Forget();
        }

        /// <summary>
        /// 発声の終了
        /// </summary>
        public override void StopSpeak()
        {
            voicevoxStop().Forget();
        }
        
        /// <summary>
        /// 読み上げ中の言葉を取得
        /// </summary>
        /// <param name="message"></param>
        public override void onSpeechRange(string message)
        {
            Debug.Log($"[Win/TTS] {message}");
            base.onSpeechRange(message);
        }
        
        /// <summary>
        /// 読み上げ開始
        /// </summary>
        /// <param name="message"></param>
        public override void onStart(string message)
        {
            Debug.Log($"[Win/TTS] {message}");
            base.onStart(message);
        }
        
        /// <summary>
        /// 読み上げ終了
        /// </summary>
        /// <param name="message"></param>
        public override void onDone(string message)
        {
            Debug.Log($"[Win/TTS] {message}");
            base.onDone(message);
        }
        
        /// <summary>
        /// エラー時
        /// </summary>
        /// <param name="message"></param>
        public override void onError(string message)
        {
            Debug.Log($"[Win/TTS] {message}");
        }
        
        /// <summary>
        /// 設定の成功時
        /// </summary>
        /// <param name="message"></param>
        public override void onMessage(string message)
        {
            Debug.Log($"[Win/TTS] {message}");
        }
        
        /// <summary>
        /// 発声開始
        /// </summary>
        /// <param name="message"></param>
        private async UniTask voicevoxPlay(string message)
        {
            _voicevoxCts = new CancellationTokenSource();
            _voicevoxMessage = message;
            onStart(_voicevoxMessage);
            
            await _voicevox.PlayOneShot(_voiceActor, message, _voicevoxCts.Token);
            
            onDone(_voicevoxMessage);
            _voicevoxCts.Dispose();
            _voicevoxCts = null;
        }

        /// <summary>
        /// 発声停止
        /// </summary>
        private async UniTask voicevoxStop()
        {
            if(_voicevoxCts == null)
                return;
            
            _voicevoxCts.Cancel();

            //念のため
            await UniTask.DelayFrame(1);
            
            onMessage($"{_voicevoxMessage}: 発声停止");
            
            _voicevoxCts.Dispose();
            _voicevoxMessage = null;
            _voicevoxCts = null;

        }
    }

実装のポイントはこうでしょうか。

  • VOICEVOXコンポーネントを動的に生成してシーン上に配置
  • 発声を停止するにはキャンセルトークンを手動でキャンセルさせた (voicevoxStop関数)

会話モジュールを作り直す

作り替えたTTS/STTクラスとChatGPTを連携させて会話モジュールを作ります。

ChatGPT周りは移植元のアプリと同様にChatGPT-API-unityを使わせてもらいました。

    /// <summary>
    /// 会話モジュール
    /// </summary>
    public class RgTalkForWinModule
    {

        ~~~ 割愛 ~~~

        /// <summary>
        ///  ChatGPT 結果コールバックを外部から検知するための Observable
        /// </summary>
        public IObservable<(ChatCompletionResponseBody, Exception)> ChatGptResultCallback => _chatGptResultCallback;

        
        /// <summary>
        /// 初期化
        /// </summary>
        /// <param name="setting"></param>
        public void Init(RgaChatGptSetting setting)
        {
            _chatGptSetting = setting;
            _chatGptCts = null;

            _chatGptResultCallback = new Subject<(ChatCompletionResponseBody, Exception)>();
            
            //文脈履歴
            _memory = new FiniteQueueChatMemory(setting.MaxMemoryCount);
            //AIコネクション作成
            _connection = new ChatCompletionAPIConnection(
                setting.ApiKey,
                _memory,
                setting.SystemMessage);
        }
        
        /// <summary>
        /// 音声認識装置の生成
        /// ※ Start() のライフサイクルタイミングで呼ぶこと
        /// </summary>
        public void CreateDictationRecognizer()
        {
            SpeechToTextForWin.Instance.CreateDictationRecognizer();
        }

        /// <summary>
        /// セッション開始
        /// </summary>
        public void StartSession()
        {
            _chatGptCts = new CancellationTokenSource();
            
            //設定
            var sttSet = new WinSTTParameters
            {
                AutoSilenceTimeoutSeconds = _chatGptSetting.AutoSilenceTimeoutOfWinSp,
                InitialSilenceTimeoutSeconds = _chatGptSetting.InitSilenceTimeoutOfWinSp
            };

            SpeechToTextForWin.Instance.Setting(sttSet);
            
            var ttsSet = new WinTTSParameters
            {
                Actor = _chatGptSetting.VoicevoxActor
            };

            TextToSpeechForWin.Instance.Setting(ttsSet);
            
            //音声認識結果の受け取り
            SpeechToTextForWin.Instance.onResultCallback += onSTT;
        }

        /// <summary>
        /// セッションの停止
        /// ※ ChatGPTの処理 `sendChatAsync` は即座に止まらないので注意
        /// </summary>
        public void StopSession()
        {
            //`sendChatAsync` を止める
            if(_chatGptCts != null && !_chatGptCts.IsCancellationRequested)
                _chatGptCts.Cancel();
            
            _chatGptCts?.Dispose();
            _chatGptCts = null;

            ClearChatMemory();
            
            //TTS/STT を止める
            SpeechToTextForWin.Instance.StopRecording();
            TextToSpeechForWin.Instance.StopSpeak();
        }

        /// <summary>
        /// 聞き取り開始
        /// </summary>
        public void StartListening()
        {
            
            //喋っていたら止める
            TextToSpeechForWin.Instance.StopSpeak();
            SpeechToTextForWin.Instance.StartRecording("Speak any");
        }
        
        /// <summary>
        /// 聞き取り終了
        /// </summary>
        public void StopListening()
        {
            SpeechToTextForWin.Instance.StopRecording();
            //-> コールバックから `onSTT` へ処理が遷移
        }
        
        /// <summary>
        /// 文脈クリア
        /// </summary>
        public void ClearChatMemory()
        {
            _memory?.ClearAllMessages();
        }
        /// <summary>
        /// ユーザの音声をテキストに変換後
        /// </summary>
        /// <param name="text"></param>
        private void onSTT(string text)
        {
            onSTTAsync(text, _chatGptCts.Token).Forget();
        }
        
        /// <summary>
        /// ユーザの音声をテキストに変換後
        /// </summary>
        /// <param name="text"></param>
        private async UniTask onSTTAsync(string text ,CancellationToken cancellationToken)
        {
            
            // ユーザ音声がカラ
            if (string.IsNullOrEmpty(text?.Trim()))
            {
                Debug.Log("[STT] text is null.");
                var ex = new System.Exception("The reply from ChatGPT is empty.");
                
                _chatGptResultCallback.OnNext((null, ex));
                TextToSpeechForWin.Instance.StartSpeak("すいません、聞き取れませんでした。もう一度お願いします。");
                return;
            }

            // 戻り値
            (ChatCompletionResponseBody answer, System.Exception exception) ret;
            
            // 命令内容
            text = "必ず50文字以内で答えて下さい。" + text;
            
            // ChatGPTに命令
            ret = await sendChatAsync(text, cancellationToken);

            // キャンセル済みなら何もしない
            if (cancellationToken.IsCancellationRequested)
            {
                var ex = new ChannelClosedException();
                _chatGptResultCallback.OnNext((null, ex));
                return;
            }

            // ChatGPT処理過程で例外が発生した
            if (ret.exception != null)
            {
                _chatGptResultCallback.OnNext(ret);
                TextToSpeechForWin.Instance.StartSpeak("ChatGPT でエラーが発生しました");
            }
            else
            {
                var message = ret.answer.ResultMessage?.Trim();
                if(string.IsNullOrEmpty(message))
                    message = "すいません、わかりません。";
                
                _chatGptResultCallback.OnNext(ret);
                TextToSpeechForWin.Instance.StartSpeak(message);
            }
        }
        
        /// <summary>
        /// ChatGPT に話しかける
        /// </summary>
        /// <param name="message"></param>
        /// <param name="cancellationToken"></param>
        /// <returns>(ChatCompletionResponseBody, System.Exception) : 返答内容, 例外</returns>
        private async UniTask<(ChatCompletionResponseBody, System.Exception)> sendChatAsync(string message, CancellationToken cancellationToken)
        {
            Debug.Log($"[ChatGPT] Request:n{message}");

            ChatCompletionResponseBody ret = null;
            Exception exception = null;
            
            try
            {
                await UniTask.SwitchToThreadPool();
                
                ret = await _connection.CompleteChatAsync(
                    message,
                    cancellationToken,
                    _chatGptSetting.ChatModel);
            }
            catch (Exception e)
            {
                exception = e;
                Debug.LogException(e);
            }

            await UniTask.SwitchToMainThread(cancellationToken);

            Debug.Log($"[ChatGPT] Result:n{ret?.ResultMessage}");

            return (ret, exception);
        }
    }

実装のポイントは前回記事と同様に特にありません。
Speech And Text in Unity iOS and Unity Androidと処理手順を似せたことで簡単にChatGPTと連携させることができました。

シーンの再構築

Design – シーン

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Design/Scene.html

ドキュメントに従って次のことを行いました。

  1. 箱庭空間を認識しやすくする土台を置く
  2. メインで見せたいオブジェクトを中央に置く
  3. クリッピングとスケールの調整をする

それぞれ説明していきます。

箱庭空間を認識しやすくする土台を置く

シーン配置

見え方のイメージ

筐体の床面とディスプレイ内の箱庭空間の地面が地続きであるとリアリティが増すとのとこなので、そのようにしてみました。

ロボガールに世界観がマッチするように工場のようなモデルを配置しました。
凹凸のある地面ですが極力筐体の床面と一致するようにしました。

メインで見せたいオブジェクトを中央に置く

メインで見せたいのはロボガールなので、上のスクショのように中央に配置しました。
またロボガールは宙に浮くのですが、なるべく爪先は地面に近いところまでいくようにしました。
(地面と対比して空間のどこにいるかユーザが分かりやすくするため)

クリッピングとスケールの調整をする

Is Spatial Clipping Active

空間クリッピング

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/SpatialClipping.html

下のスクショの青のGIZUMOボックスが筐体で投影される立体空間です。
なのでこの空間を超えて描画されると見映えが悪くなります。

今回作るアプリのシーンだけでは分かりづらいので…
上のスクショのように横にした円柱を置いてIs Spatial Clipping ActiveのON/OFFの時を比較してみます。

Is Spatial Clipping Active: OFF

Is Spatial Clipping Active: ON

Is Spatial Clipping Active はデフォルトでONになっているので基本的にはそのままで良いと思われます。

SRD View Scale Space

SR Display View Space のスケーリング

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/WorldScaling.html

Unity 単位 “1” は1mのスケールです。
Untiyに取り込んだアセットは大抵このスケールに合わせています。

しかし本機の表示域は593.2 mm×332.8 mmです。
これに合わせて個々のモデルのスケールを変更してもいいのでしょうがそれは面倒ですよね。
(その場合SRD View Scale Space = 1のまま)
SR Display View Spaceの値を変えることで、シーン上の立体空間(青いGIZOMOボックス)のスケールを調整しやすいようになっています。

SR Display View Space = 1

SR Display View Space = 14

UIとEventSystemの変更

ビジュアルデザイン

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Design/Visual-Design.html

本機のディスプレイはユーザの目視からすると、平面ではなくて立体として視えます。
ですのでUIパーツは立体空間に浮くように配置するべきです。

付属のサンプルアプリ3 – SRDisplayUISampleを見てみると、World SpaceのCanvasに対してマウス入力を受け付けるようにしています。
またSR Display View Spaceをランタイムで変更してもUIのスケールが変更されないようにもなっていました。

これらを実現していたのは次のコンポーネントたちでした。

SRDGraphicRaycaster

  • Canvasのコンポーネントが付いているゲームオブジェクトに付ける
  • 内部ではSRDCamerasのScreenToWorldPoint関数がディスプレイ面から立体空間のワールド座標を返してくれている

SRDStandaloneInputModule

  • EventSystemのコンポーネントが付いているゲームオブジェクトに付ける
  • 内部ではSRDCamerasのWorldToSRDScreenPoint関数がワールド座標からディスプレイ面の座標に変換している

SRDViewSpaceScaleFollower

  • Canvasやその親のゲームオブジェクトに付ける
  • Absolute FollowがOFFのままならSR Display View Spaceの影響を受けず元々のスケールが維持される

本アプリでも同様に処置を行いました。

検出した顔の位置を使ってみる

検出した顔の位置を取得してみよう

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/HowToGetFacePose.html

検出した顔の位置(Transform)はSRDisplayManager配下のWatcherAnothorに常時反映されています。

ロボガールの顔がユーザの方に傾くようにしました。
RgaRoboGirlViewはロボガールのルートとなるゲームオブジェクトに付けます。

    /// <summary>
    /// ロボガールの動きの制御
    /// </summary>
    public class RgaRoboGirlView : MonoBehaviour
    {
        /// <summary>
        /// 首の Transform
        /// </summary>
        [Header("首の制御"), SerializeField] private Transform _neckTransform;

        /// <summary>
        /// プレイヤーの Transform
        /// </summary>
        [SerializeField] private Transform _playerTransform;

        /// <summary>
        /// 前方の基準となるローカル空間ベクトル
        /// </summary>
        [SerializeField] private Vector3 _forward = Vector3.forward;

        private void Start()
        {
            // 首追従計算
            this.UpdateAsObservable().Subscribe(_ =>
            {
                // プレイヤーへの向きベクトル計算
                var dir = _playerTransform.position - _neckTransform.position;
            
                // プレイヤーの方向への回転
                var lookAtRotation = Quaternion.LookRotation(dir, Vector3.up);
                // 回転補正
                var offsetRotation = Quaternion.FromToRotation(_forward, Vector3.forward);
            
                // 回転補正→ターゲット方向への回転の順に、自身の向きを操作する
                _neckTransform.rotation = Quaternion.Slerp(_neckTransform.rotation, lookAtRotation * offsetRotation, 0.05f);
            });
        }
    }

ポストプロセス対応

ロボガールの蛍光パーツなどをブルームさせるためにポストプロセスに対応させました。

付属のサンプルアプリ4 – SRDisplayPostProcessingSampleを見てみると、
SRDisplayManager配下のRightEyeCameraとLeftEyeCameraのインスペクタ設定のPostProcessingがONになっていました。
(WatcherCameraもPostProcessingがONになっていましたが、おそらく不要です。)

起承転結の制御

ボタンを押したら喋って、ボタンを離すとロボガールが喋るようにします。
会話モジュール(RgTalkForWinModule) とボタン(_speechButton)を連結させて STT > ChatGPT > TTS させています。

RgaMainForWinBehaviourは適当に作った空のゲームオブジェクトに付けます。

    /// <summary>
    /// シーンの起承転結 (Win)
    /// </summary>
    public class RgaMainForWinBehaviour : MonoBehaviour
    {
        
        ~~~ 割愛 ~~~

        private void Awake()
        {
            _chatGptModule = new RgTalkForWinModule();
            _chatGptModule.Init(_setting);
            
            //録音ボタン押す・離す
            _speechButton.OnButtonDownAction = onSpeechButtonDown;
            _speechButton.OnButtonUpAction = onSpeechButtonUp;
            _speechButton.gameObject.SetActive(false);
        }

        private void Start()
        {
            //音声認識装置の生成
            _chatGptModule.CreateDictationRecognizer();
            
            //ChatGPT 通信結果の受け取り後
            _chatGptModule.ChatGptResultCallback.Subscribe(
               response =>
               {
                   setUI(response);
               });
            
            _chatGptModule.StartSession();
            
            setLoader(false);
            
            //ローダーとボタンの表示設定
            void setUI((ChatCompletionResponseBody chatRes, System.Exception ex) response){
                
                Debug.Log($"setUI: {response.chatRes?.ResultMessage},{response.ex}");
                
                //ローダー非表示
                setLoader(false);
                //ボタンのインタラクション有効
                _speechButton.Interactable = true;
            }
        }
        
        /// <summary>
        /// マイクボタン押下直後
        /// </summary>
        private void onSpeechButtonDown()
        {
            _chatGptModule.StartListening();
        }

        /// <summary>
        /// マイクボタン離した直後
        /// </summary>
        private void onSpeechButtonUp()
        {
            // 音声認識装置 が聞き終わるまで時差があるため
            delayStopListening(3.0f);
        }

        /// <summary>
        /// マイクボタン離した直後
        /// </summary>
        /// <param name="delaySec"></param>
        private async void delayStopListening(float delaySec)
        {
            //ローダー表示
            setLoader(true);

            // 音声認識装置が聞き終わるまで時差があるためすぐに停止させない
            await UniTask.Delay(TimeSpan.FromSeconds(delaySec));
            
            //停止
            _chatGptModule.StopListening();
            
            //onSTTAsync()が終わるまで無効
            _speechButton.Interactable = false;
        } 
        
        /// ローダー表示非表示
        /// </summary>
        /// <param name="active"></param>
        private void setLoader(bool active)
        {
            if(_loadingAnimation.activeSelf == true && active == false)
                GameObject.Destroy(_thinkingSE);
            else if(_loadingAnimation.activeSelf == false && active == true)
                _thinkingSE = GameObject.Instantiate(_thinkingSEPrefab);
            
            _loadingAnimation.SetActive(active);
        }
    } 

はい、これでアプリは完成です!!
早速動かしてみましょう~~

Unityyエディタだけで実行する

SR Displayを非接続状態で使用する

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/WithoutDevice.html

ELF-SR2は皆でシェアして使うし、自宅で作業する場合もあるのでRunWithoutモードを重宝しています。

※ 筐体に未接続かつ、RunWithoutモードがオフだとエラー(下図)が出てしまいます。

このようにマウスで視点の操作(右クリックでドラッグ&ホイール)ができます。

実機で動かしてみる

実機で動かす場合は二通りのやり方があります。

Unityエディタから動かす

Unity EditorでPlayModeを使用する

https://www.sony.net/Products/Developer-Spatial-Reality-display/jp/develop/Unity/PlayMode.html

GameViewウィンドウを予め閉じておかないとPCディスプレイ側がフルスクリーンでSRDisplay GameViewになってしまいました。気をつけましょう。

実際に試したのが下の動画になります。
F11キーを連打してELF-SR2側のSRDisplay GameViewの表示の切り替えを繰り返しています。
その後エディタ実行を開始してELF-SR2側でアプリが出力されています。

実行ファイル(exe)を作って動かす

あとはUnityエディタでWindowsプラットフォーム向けにビルドするだけです。
exeファイルを実行すれば自動で接続されているELF-SR2側に出力されます。

今回作ったアプリの最終形はこんな感じです ↓

動画越しでは伝わり辛くて歯痒いのですが…
肉眼だとロボガールがディスプレイから浮き出てきて本当にその場にいるように見えます。
ロボガールのメタリックな質感や蛍光パーツも相まって現実に “そこにいる存在感” が凄いです。

おわりに

いかがでしたか?

動画では分かりづらいかもしれませんが、肉眼で見るとそのリアルさというか、繊細で美麗な立体投影に感動してしまいます。

実際にELF-SR2のアプリを作ってみても、シーン上のオブジェクト配置やUI/UXのデザインのために最初はそこそこ覚えることはありますが、SDKはシンプルで分かりやすいので一度覚えれば意外と簡単にそれなりのアプリが作れました。

ELF-SR2は中々のお値段なのでおいそれと手を出せるものではないと思いますが、もし触れる機会が訪れましたら、是非アプリを作ってみてください!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる