OpenAI の Realtime API を Unity で利用する
はじめに
OpenAI からすばらしい API が発表されましたね。その少し前に ChatGPT アプリでも音声でのやり取りが劇的に向上したアップデートがありました。Realtime API はそれをアプリケーションから利用できるようにしたものです。今回はこの Realtime API を Unity で利用する方法を紹介します。
特に今回解説するのは「音声入出力」の部分についてです。
実際に Unity を使って XREAL という AR グラスで利用するデモアプリを作成しました。以下がその動画です。
Realtime API は Function Calling にも対応しています。そのため上の動画のように、通常の会話をしつつ指定したツールの起動が推測される場合にはその旨の返信が API を通じて送られてきます。そのデータに応じて関数を実行することで、動画のように特定の機能を音声コマンドのように呼び出すことができます。
OpenAI の記事紹介
Realtime API についての OpenAI の記事です。現在の状況や今後の展望などについて書かれているので興味がある人は読んでみてください。
利用料金
Realtime API は通常の API に比べて割高になっているので利用する際は気をつけてください。料金については上の記事で以下のように言及されています。
The Realtime API uses both text tokens and audio tokens. Text input tokens are priced at $5 per 1M and $20 per 1M output tokens. Audio input is priced at $100 per 1M tokens and output is $200 per 1M tokens. This equates to approximately $0.06 per minute of audio input and $0.24 per minute of audio output. Audio in the Chat Completions API will be the same price.
表にまとめると以下のようになります。(2024/11/11 現在)
トークンタイプ | 料金 | 1 分あたりのコスト |
---|---|---|
テキスト入力 | $5 / 1M トークン | – |
テキスト出力 | $20 / 1M トークン | – |
音声入力 | $100 / 1M トークン | $0.06 |
音声出力 | $200 / 1M トークン | $0.24 |
※ 料金は変更されることがあるため、実際の料金については公式の料金ページをご確認ください。
▼ 料金ページ
Realtime API の利用について
ここからは今回の主題である Realtime API の利用方法について説明していきます。ただ、今までの API と比べると利用の仕方がかなり異なっているため、まずは概観して全体像を把握しましょう。
▼ ドキュメント
WebSocket による通信を利用する
一番大きな違いは、Realtime API は WebSocket を使って通信を行う必要がある点です。
なぜ WebSocket なのでしょうか。Realtime API の名前の通り、API とのやり取りはリアルタイムに行われます。つまり、コネクションを張った状態で常にデータの送受信が必要であるために WebSocket によるリアルタイム通信が必要なのです。
WebSocket の接続情報
WebSocket での接続情報に関して以下のようにドキュメントに記されています。
- URL:
wss://api.openai.com/v1/realtime
- Query Parameters:
?model=gpt-4o-realtime-preview-2024-10-01
- Headers:
Authorization: Bearer YOUR_API_KEY
OpenAI-Beta: realtime=v1
イメージがしやすいと思うのでドキュメントに記載のある JavaScript によるコード例も載せておきます。
import WebSocket from "ws";
const url = "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01";
const ws = new WebSocket(url, {
headers: {
"Authorization": "Bearer " + process.env.OPENAI_API_KEY,
"OpenAI-Beta": "realtime=v1",
},
});
データの送受信
データの送受信は WebSocket のコネクションを利用して行います。使い方は通常の WebSocket と同様です。
データの送受信は JSON で行われ、その中に type
フィールドがあります。これは「どんなデータ・タイプか」を示しており、例えば音声出力のレスポンスは以下のような JSON になります。
{
type: 'response.audio.delta',
event_id: 'event_AERdZyneIbz2imxjcrlOI',
response_id: 'resp_AERdYH6zIWGsJh3VjJVn3',
item_id: 'item_AERdYazUlwBWW4v0w4WlP',
output_index: 0,
content_index: 0,
delta: '<BASE64_ENCODED_STRING>'
}
音声データは送受信ともに Base64 エンコードしたものです。受信時は音声データ断片( delta
)が送られてきます。送信時も同様に断片で送ることもできますし、音声ファイルをまとめて Base64 エンコードして送ることもできます。
音声データの送信についてもドキュメントの JavaScript コードを載せておきます。雰囲気を掴んでください。
音声データの送信
import fs from 'fs';
import decodeAudio from 'audio-decode';
// Converts Float32Array of audio data to PCM16 ArrayBuffer
function floatTo16BitPCM(float32Array) {
const buffer = new ArrayBuffer(float32Array.length * 2);
const view = new DataView(buffer);
let offset = 0;
for (let i = 0; i < float32Array.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, float32Array[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return buffer;
}
// Converts a Float32Array to base64-encoded PCM16 data
function base64EncodeAudio(float32Array) {
const arrayBuffer = floatTo16BitPCM(float32Array);
let binary = '';
let bytes = new Uint8Array(arrayBuffer);
const chunkSize = 0x8000; // 32KB chunk size
for (let i = 0; i < bytes.length; i += chunkSize) {
let chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
// Using the "audio-decode" library to get raw audio bytes
const myAudio = fs.readFileSync('./path/to/audio.wav');
const audioBuffer = await decodeAudio(myAudio);
const channelData = audioBuffer.getChannelData(0); // only accepts mono
const base64AudioData = base64EncodeAudio(channelData);
const event = {
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [
{
type: 'input_audio',
audio: base64AudioData
}
]
}
};
ws.send(JSON.stringify(event));
ws.send(JSON.stringify({type: 'response.create'}));
音声を用いた AI との会話について
以前の API を利用しているとつい 「会話の開始と終わり」をどうハンドリングするのか という疑問が出てきてしまうかと思います。(自分は出てきました)
しかし、結論から言うと デフォルトでは「なにもしなくても会話できる」 となります。
デフォルトでは VAD がオン
なぜかというと、 デフォルトではサーバは VAD (voice activity detection) モードがオン になっているためです。
VAD モードとは音声データの中から会話の終わりを自動検知する仕組みです。簡単に言うと「無音状態」を検知してくれるものです。つまり、マイクに向かって話し続けている間はサーバ側は音声の返答をせず、VAD により会話の終わりが検知されたらレスポンスの生成が始まります。
またこのモードのすばらしい点としては、仮に AI が話している状態(= サーバから音声のレスポンスイベントが届き続けている状態)で話しかけると、自動的に音声レスポンスイベントが停止します。つまり AI の発話が止まります。
これにより、アプリ開発者はユーザの発話の開始・終了を制御せずとも自動的に会話アプリが作れる、というわけなのです。また、こうしたリアルタイム性ゆえに WebSocket が用いられているのでしょう。
Unity での実装解説
さて、ここからは実際に Unity で実装した内容を元に Realtime API の利用方法について説明していきます。
Unity で WebSocket を使う
まずは WebSocket についてです。Unity で WebSocket を使う方法はとてもシンプルです。.NET に WebSocket 実装があるためそれを利用します。(ただ、過去の Unity の .NET バージョンだと存在していなかった気がするので、古いバージョンだと使えないかもしれません)
.NET の実装では System.Net.WebSockets
namespace にある ClientWebSocket
クラスを使います。セットアップは以下です。
using System.Net.WebSockets;
// ------------------------------
_client = new ClientWebSocket();
_client.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
_client.Options.SetRequestHeader("OpenAI-Beta", "realtime=v1");
await _client.ConnectAsync(new Uri(_apiUrl), destroyCancellationToken);
上で書いたように、 Authorization
と OpenAI-Beta
のヘッダをセットして接続します。
接続は非同期メソッドになっているため await
で接続を待ち、接続されたらサーバからのデータを受信するループを開始します。
WebSocket での受信処理
WebSocket で接続したら、API からのレスポンスを受信するループを作ります。この中でリアルタイムに受信したデータをパースして利用していきます。
await ReceiveAsync(cancellationToken);
private async UniTask ReceiveAsync(CancellationToken cancellationToken)
{
ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[8192]);
while (_client.State == WebSocketState.Open)
{
WebSocketReceiveResult result = null;
using (MemoryStream stream = new MemoryStream())
{
do
{
// サーバからのレスポンスを非同期で待ちづづける
// メッセージがくるまではこの先に進まない
result = await _client.ReceiveAsync(buffer, cancellationToken);
// result.Count は受信したデータのサイズ
stream.Write(buffer.Array, buffer.Offset, result.Count);
} while (!result.EndOfMessage);
stream.Seek(0, SeekOrigin.Begin);
if (result.MessageType == WebSocketMessageType.Text)
{
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
{
string message = await reader.ReadToEndAsync();
if (_verboseLog)
{
Debug.Log(message);
}
Response response = JsonConvert.DeserializeObject<Response>(message);
HandleResponse(response); // ここでイベントを発火し、レスポンスデータを通知する
}
}
else if (result.MessageType == WebSocketMessageType.Close)
{
await _client.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken);
}
}
}
}
ArraySegment<T> について
受信ループ内で使用している ArraySegment<T>
について少し補足しておきます。
ドキュメントから引用すると、以下のようなサンプルコードが載っていました。
「Segment」の名前の通り元の配列の「一部」を示し、コンストラクタで指定した位置とサイズを持つ構造体です。
ただし、この構造体が持つ Array
フィールドは、元の配列の参照を持っているため添字をいじれば元の配列をそのまま操作できてしまうため注意が必要です。
using System;
public class Hello
{
public static void Main()
{
string[] array = { "hoge", "fuga", "foo", "bar", "piyo", "aaa", "bbb" };
PrintIndexAndValues(array);
ArraySegment<string> arraySegmentAll = new ArraySegment<string>(array);
PrintIndexAndValues(arraySegmentAll);
ArraySegment<string> arraySegmentMid = new ArraySegment<string>(array, 2, 5);
PrintIndexAndValues(arraySegmentMid);
arraySegmentMid.Array[2] = "ccc";
PrintIndexAndValues(arraySegmentMid);
PrintIndexAndValues(arraySegmentAll);
}
private static void PrintIndexAndValues(ArraySegment<string> arraySegment)
{
for (int i = arraySegment.Offset; i < (arraySegment.Offset + arraySegment.Count); i++)
{
Console.WriteLine($"[{i}] : {arraySegment.Array[i]}");
}
Console.WriteLine();
}
private static void PrintIndexAndValues(string[] array)
{
for (int i = 0; i < array.Length; i++)
{
Console.WriteLine($"[{i}] : {array[i]}");
}
Console.WriteLine();
}
}
受信ループは無限ループになっており API からのデータを常に待ち続けます。そしてデータを受信したらそれをパースし、パースしたデータをイベントを経由して通知する仕組みにしています。
パースに利用している Response
構造体は以下です。
public struct Response
{
[JsonProperty("type")] public string Type;
[JsonProperty("event_id")] public string EventId;
[JsonProperty("response_id")] public string ResponseId;
[JsonProperty("item_id")] public string ItemId;
[JsonProperty("output_index")] public int OutputIndex;
[JsonProperty("content_index")] public int ContentIndex;
[JsonProperty("delta")] public string Delta;
[JsonProperty("name")] public string Name;
[JsonProperty("arguments")] public string Arguments;
}
今回はこの中の delta
フィールドを利用します。ここに、音声の一部が Base64 エンコードされたデータが格納されています。
音声を再生する
前段で、音声データの断片が Base64 エンコードされたものが API から返されると説明しました。これを利用して実際に Unity アプリケーション上で再生する処理を見ていきましょう。
Base64 文字列をデコードする
まず最初に行うのは、渡された Base64 文字列をデコードすることです。デコード処理は以下です。
public void Append(string base64Data)
{
byte[] pcmData = Convert.FromBase64String(base64Data);
lock (_audioBuffer)
{
// PCM データ(16bit Int16)を float に変換し、-1.0f 〜 1.0f の範囲に正規化
for (int i = 0; i < pcmData.Length; i += 2)
{
short sample = BitConverter.ToInt16(pcmData, i);
// Int16 (short) の最大値で割って -1.0f ~ 1.0f に正規化
float floatSample = sample / (float)short.MaxValue;
_audioBuffer.Enqueue(floatSample);
}
}
}
Base64 文字列は System.Convert.FromBase64String()
メソッドで簡単にバイト配列に復元できます。
PCM16 音源のデータを加工する
このバイト配列は PCM16 音源データを格納したものになっています。しかし、Unity ではそのまま利用できないため少し加工が必要です。
具体的には、PCM16 音源データは 16bit でサンプルデータを表しており、つまり -32,768 ~ 32,767
の範囲で保存されています。しかし Unity はこれを正規化した -1.0f 〜 1.0f
の範囲で表す必要があり、そのための加工がループ処理内で行われています。
以下の部分ですね。
short sample = BitConverter.ToInt16(pcmData, i);
// Int16 (short) の最大値で割って -1.0f ~ 1.0f に正規化
float floatSample = sample / (float)short.MaxValue;
今回の実装では加工した float
のデータを独自で定義したバッファクラスに追加しています。後述する音声再生のところで、順次バッファから取り出して音声バッファに格納するためにこうしています。
バッファから取り出して再生する
Realtime API から受信した音声データをデコードし、さらに Unity が求める float
型にしてバッファリングしました。これを再生していきます。
再生処理には Unity API である OnAudioFilterRead
メソッドを利用します。
OnAudioFilterRead とは
実際の利用の前に、少しだけこのメソッドについて解説します。
OnAudioFilterRead is called every time a chunk of audio is sent to the filter (this happens frequently, every ~20ms depending on the sample rate and platform). The audio data is an array of floats ranging from [-1.0f;1.0f] and contains audio from the previous filter in the chain or the AudioClip on the AudioSource. If this is the first filter in the chain and a clip isn't attached to the audio source, this filter will be played as the audio source. In this way you can use the filter as the audio clip, procedurally generating audio.
このメソッドはメインスレッドとは別の音声用スレッドで実行されます。また、プラットフォームごとやサンプリングレートごとに異なるタイミングで呼ばれます。
このメソッドは本来は名前の通り、音声に対してフィルタをかけるために利用されます。そのため、本来の使い方ではメソッドの引数である float[] data
には次に再生する予定の音声データのチャンクが含まれています。これに手を加えることでフィルタ(加工)することができる、というわけですね。
しかし今回はフィルタのためではなく、音声再生のために利用します。ドキュメントにも以下のような記載があります。
In this way you can use the filter as the audio clip, procedurally generating audio.
float[] data
は次に再生される予定の音声のチャンク(バッファ)ということは、これに適切にデータを詰めることができればその音声を再生することができる、というわけです。(なので例えばバッファをすべてゼロで埋めれば、音声データを無音にすることも可能です)
この前提を元に、今回の実装コードを見てみてください。
private void OnAudioFilterRead(float[] data, int channels)
{
if (_audioBuffer == null || _audioBuffer.IsEmpty) return;
lock (_audioBuffer)
{
for (int i = 0; i < data.Length; i += channels)
{
if (_audioBuffer.IsEmpty) return;
// バッファからデータを取得
float sample = _audioBuffer.Dequeue();
data[i] = sample;
// ステレオの場合は同じ値を次のチャンネルにもコピー
if (channels == 2)
{
data[i + 1] = sample;
}
}
}
}
前段でバッファに float
の値を追加していたことを思い出してください。そのバッファから取り出して音声バッファに詰めなおしている、というわけです。基本的にはただの float
配列なので、そこに次の音声再生に必要な分のデータを詰め込めば OK です。
この float[] data
はチャンクで渡されるためおよそ 2024
サイズくらいのバッファが渡されてきます。また、第二引数にはチャンネル数が渡ってくるため、必要に応じて両チャンネル分のデータを格納します。Realtime API から渡されるデータはモノラルなので、ここではシンプルに両チャンネルに同じデータを入れています。
ちなみにもし API から音声データが届いていない場合は処理を中断し、なにもしないようにしています。今回の場合はそもそも AudioClip
がアタッチされていないので結果的に無音になる、というわけです。
AudioSource
が再生中であればこのメソッドは必要なタイミングで毎回呼び出されるため、Realtime API からのレスポンス(音声データ)がありさえすれば自動的に音声が再生されるわけです。
マイク音声を送信する
Realtime API からの音声レスポンスを再生する方法について書きました。テキストを送って受信を音声に、ということもできるのでマイクがなくても音声レスポンスをテストすることはできます。
しかし、やはりせっかくなら 会話 したいですよね。ということで、次はマイク音声を録音しそれを Realtime API に送信する部分の実装を解説していきます。
今回はシンプルな実装にしたいので Unity が提供するマイク機能を利用します。
音響エコーキャンセリングについて
スマホなどを利用する場合、今回の実装だとスピーカーから流れた AI の音声がそのままマイクが拾ってしまって会話が無限ループするという問題があります。
これを解決する方法として音響エコーキャンセリング(Acoustic Echo Cancellation、以下 AEC)という方法があります。これは Unity の機能ではなく、一般的な問題としてスピーカーからの音をマイクが拾ってしまう問題を対処するためのものです。
調べてみたところ、iOS や Android のネイティブ開発の場合はデフォルトで備わっているので問題ないようなのですが、Unity 開発の場合はこれが有効になっていないためにこの問題が発生してしまいます。
いちおう、Unity が買収した Vivox という音声チャットアプリを簡単に開発できるフレームワークを使うことでこの AEC を利用することが可能です。が、音声チャット用ということと、マイクの設定を自分で変更できないので少し取り扱いに難がありました。(動かすことはできました)
Unity のアセットストアにそうしたマイクのアセットもあるようなので、スマホ向けに作る場合などはそちらを検討してみてもいいかもしれません。
マイクのセットアップ
ではまず、Unity 標準のマイク機能をセットアップします。今回実装したコードは以下です。
private async UniTask StartMicAsync(CancellationToken cancellationToken)
{
// 事前に設定したデバイスでマイクを開始
_audioSource.clip = Microphone.Start(_targetDevice, true, 10, 24000);
_samples = new float[_audioSource.clip.samples * _audioSource.clip.channels * _timeLength];
// マイクがスタートするのを待つ
while (Microphone.GetPosition(_targetDevice) <= 0)
{
await UniTask.Yield(cancellationToken);
}
Debug.Log("The mic started.");
_audioSource.Play();
}
マイクを開始するデバイスは事前に取得しておきます。必要であれば選択する UI などを作ってユーザに選択させるとよいでしょう。
マイクが起動するとバッファがマイク音声で埋められるため GetPosition()
の値が 0
以上になります。それを待ってからマイク開始として処理を進めます。
マイクから音声データを取得
マイクが無事に起動したら、マイクが録音したデータを取り出します。具体的には以下のようにします。
// Buffer はただのリストオブジェクト
private List<byte> _buffer = new List<byte>();
// ------------------------------
private void Recording()
{
_buffer.Clear();
int currentPosition = Microphone.GetPosition(_targetDevice);
_audioSource.clip.GetData(_samples, 0);
// 前回位置(_currentPosition)より現在位置の方が大きい場合は
// 前回位置から現在位置までの間のデータをキューに追加
if (currentPosition > _previousPosition)
{
for (int i = _previousPosition; i < currentPosition; i++)
{
byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(_samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
_buffer.AddRange(sampleData);
}
}
// 前回位置より現在位置の方が小さい場合は
// 前回位置から最後までのデータと最初から現在位置までのデータをキューに追加
else if (currentPosition < _previousPosition)
{
int sampleLength = _audioSource.clip.samples * _audioSource.clip.channels;
for (int i = _previousPosition; i < sampleLength; i++)
{
byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(_samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
_buffer.AddRange(sampleData);
}
for (int i = 0; i < currentPosition; i++)
{
byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(_samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
_buffer.AddRange(sampleData);
}
}
byte[] sampleDataArray = _buffer.ToArray();
string base64 = Convert.ToBase64String(sampleDataArray);
OnRecorded?.Invoke(base64);
_previousPosition = currentPosition;
}
ちょっと長いのでひとつずつ見ていきましょう。まず、取り出したデータを加工して保持しておくために List<byte>
型のバッファ用オブジェクトを作成しておきます。
まず最初に、マイクバッファの現在位置を GetPosition()
によって取得します。マイクが保持しているバッファはリングバッファとなっており、返される位置(Position)は前回よりも「前に」なっている可能性がある点に注意が必要です。
そのため、取得した位置が前回位置よりも前か後かで処理を分けています。しかしこの分岐は、リングバッファに適切にアクセスするための手段が異なるのみで処理自体はどちらも同じです。
実際に行っている処理を見てみましょう。
byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(_samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
ここで行っているのは、音声再生のときにも話したように Unity が管理しているデータと Realtime API が期待する形で異なるためその変換を行っています。
前回は short
型を float
型に正規化して変換していましたが、ここはその逆の変換を行っています。具体的には、 -1.0f 〜 1.0f
で表現されてる float
の値を short
型の値の最大・最小の値にマッピングしています。マッピング後 byte
型に変換します。
そして変換したデータをひとまずリスト型のオブジェクトに追加していきます。
Base64 エンコードする
すべての音声バッファについて加工が終わったら、取得できた byte
配列を Base64 エンコードします。
byte[] sampleDataArray = _buffer.ToArray();
string base64 = Convert.ToBase64String(sampleDataArray);
変換は System.Convert.ToBase64String()
メソッドを使います。指定した byte
配列を Bsae64 文字列に変換してくれます。
あとはこれを Realtime API に送信すれば、晴れて AI に音声を届けることができます。
音声を送信する
最後に、Base64 に変換したデータの送信部分を確認します。
private void SendVoice(string base64RecordedData)
{
AudioApiRequest request = new AudioApiRequest()
{
type = "input_audio_buffer.append",
audio = base64RecordedData,
};
string json = JsonUtility.ToJson(request);
SendAsync(json, destroyCancellationToken).Forget();
}
public async UniTask SendAsync(string message, CancellationToken cancellationToken)
{
byte[] encoded = Encoding.UTF8.GetBytes(message);
ArraySegment<byte> buffer = new ArraySegment<byte>(encoded, 0, encoded.Length);
await _client.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken);
}
音声データを送信する際の type
は input_audio_buffer.append
です。また、 audio
フィールドに Base64 エンコードした音声データを付与します。
あとはこれを JSON に変換して API に投げれば完了です。
基本的にはマイクからの音声データを毎フレーム取得し送信し続けるだけで会話アプリが成立します。理由は、前述したようにサーバ側で無音状態を検知してくれるため、マイクが音を拾っている間は API からレスポンスが来ません。
そしてユーザが発話を止めると、(発話していないほぼ無音の)音声データが API に投げられることになり、サーバ側の VAD 機能により自動的に応答が返ってくる、というわけです。
最後に
今回は音声の入出力部分のみに絞って解説しました。冒頭の動画では Function Calling も実装したものになっていますが、基本的なデータの取り扱いは今回解説したものとほぼ同じです。WebSocket のセッション内で様々なイベントタイプでデータが飛んでくるので、それぞれを適切にバッファリングして利用する形になります。
こちらの発話によって自動的に停止してくれるのはとてもありがたいしより自然な「会話」になります。
今回の記事が様々なアプリの開発に役立ってもらえたら幸いです。
エンジニア絶賛募集中!
MESON では Unity エンジニアを絶賛募集中です! XR のプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!
MESONのページからご応募いただくか、X (旧 Twitter)の DM などでご連絡ください。
書いた人
比留間 和也(あだな:えど)
カヤック時代に Web エンジニアとしてリーダーを務め、その後 VR に出会いコロプラに転職。 コロプラでは仮想現実チームにて XR コンテンツ開発に携わる。 DAYDREAM 向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでも AR/VR の開発をしており、インディー部門で TGS に出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。
MESON Cases
MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。
Discussion