microCMS+Next.jsで爆速でブログを作る

こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。

今回は特に書くことがないのですが、興味のあったヘッドレスCMSを触ってみたいなと思い、

無料で手軽に使うことができるmicroCMSを使ってブログを作ってみようと思います。

microCMSは無料枠があり、3個のAPIまで無料ということで、今回の要件程度であれば余裕で使えます。

サービス作成

早速、microCMSでサービスを作成していきます。

アカウント登録したら、すぐこの画面に行くと思います。

サービス作成には2種類あり、「1から作成する」「テンプレートから選ぶ」があります。

フロントエンドがすでにある、独自で作成するなどの要件であれば、「1から作成する」がおすすめです。

とはいえ、APIのテンプレートもあるので、簡単にAPIを作ることができます。

今回は、「テンプレートから選ぶ」を選択しました。

テンプレート選択

今回は、ブログ作成のため「個人ブログ」を選択しました。

microCMSには、Next.jsのテンプレートが用意されているので、非常にありがたいですね。

開発環境の構築

続いて、GitHub連携や環境構築になります。

GitHubの連携と任意のリポジトリ名を入力して進むと、Next.jsで作成されてブログテンプレートのリポジトリが作成されます。

開発環境も手順通りに設定していきます。

まずは環境変数です。microCMSがすでに用意しているAPIキーとドメイン名(サービスID)を指定します。

// .env
MICROCMS_API_KEY=xxxxxxxxxx
MICROCMS_SERVICE_DOMAIN=xxxxxxxxxx
BASE_URL=http://localhost:3000

続いて、パッケージのインストールとプログラムの立ち上げをします。

npm install
npm run dev

Node.jsが18以上が推奨なので、ご注意ください。 自分はnodeのバージョンが古かったので、失敗しました。

問題なく起動すれば、 http://localhost:3000/にアクセスすると、以下のページが表示されます。

Vercelにデプロイ

これも案内通り操作していきます。

デプロイ前に、Vercel側のEnvironment Variablesを設定します。

MICROCMS_API_KEY=xxxxxxxxxx
MICROCMS_SERVICE_DOMAIN=xxxxxxxxxx
BASE_URL=https://<YOUR-PROJECT-NAME>.vercel.app

BASE_URLはVercelのDomainを設定します。 

デプロイが完了し、 管理画面にて、タグ、ライター、記事を追加すると、以下のような画面になります。

フロントエンドのアップデート

これだけだと、microCMSの手順通りに作業をしただけになるので、フロントエンド部分の変更をしていきます。

今回は爆速で作るので、muiのblogテンプレートを参考にします。

mui.com

microCMSとmuiのプログラムの作りが違っているので、いい感じにマージしました。

トップページはこんな感じ

余談ですが、記事の内容はChatGPTで生成しています。サンプルデータを何件も作成しないといけない時とかほんとに助かりますよね。

nandemo-blog.vercel.app

ブログの詳細ページは今回変更していませんが、ヘッダーやフッダーが更新されているので、印象が少し変わりますね。

ソース

github.com

最後に

今回はかなり軽ーい内容ですが、爆速でヘッドレスCMSでブログを作成しました。

ヘッドレスCMSは以前にStrapiを使ったことがあり、色々設定が面倒で苦労した経験があったので、今回も苦労ポイントをがあるだろうなと想像していたのですが、全くそんなことなく、microCMSは非常に簡単に環境構築、API作成が行えて、びっくりしました。

引き続きフロントエンドに関する記事を書いていきます。

最後まで読んでいただきありがとうございました。

Unity向けの簡易的なVADライブラリの紹介

こんにちは、エンジニアの渡辺(@mochi_neko_7)です。

今回は VAD (Voice Activity Detection、音声区間検出) を Unity で利用するためのライブラリを自作してみたのでこちらを紹介します。

github.com

Unity で発話している音声区間を判定したり、その音声データを切り取って何かに利用したい場合に便利なライブラリになっています。

ざっくりした説明は README にも書いてはいるのですが、こちらの記事では背景や設計などもう少し踏み込んだ話も紹介します。

以前の記事

synamon.hatenablog.com

で紹介した Logging のライブラリを使用するため、Unity のバージョンは Unity 2022.3 以上になっている点にご注意ください。

背景

※ ライブラリの説明だけ知りたい方は読み飛ばしてもらって大丈夫です。

これまで趣味で ChatGPT の API を Backend とした Chat Agent や AITuber の仕組みを開発していますが、 実は音声入力ベースの仕組みを作ることを意図的に避けていました。

OpenAI の Whisper の API は公開当初に Unity 向けの Client Library も作っていたのですが、実際にマイクの音声を入力して使用するとなると、録音した音声をそのまま全て API に流すのはコスト的にも挙動的にも現実的ではありません。*1*2

そのため、人間が話をしていることを検出し、その話をしている区間の音声のみ切り出して API に流す、といった工夫が必要になります。

その際に利用するのが、今回のメインテーマのいわゆる VAD (Voice Activity Detection、音声区間検出) で、要するに音声入力がアクティブなのか否か判定したいという話です。

もちろん自作する前に既存の VAD ライブラリなどないか調べてみたのですが、Photon Voice や Vivox、WebRTC などの音声通信ライブラリに組み込まれているものは知っているものの、GitHub にも Asset Store にも単体で使えそうなものが見当たりませんでした。(どなたか知っていたら教えていただきたいです。)

VAD のロジックは真面目に考えると WebRTC 内部での実装MFCC(メル周波数ケプストラム係数) など音声解析ベースでの実装が王道ではありますが、音声解析は計算コストが大きいので最適化も考えると実装が大変です。

以前 Photon Voice の VAD の調整をしていた感触から音量ベースのシンプルな実装でも十分実用性がありそうだと思っていましたので、いったんは音量ベースの簡易的な VAD ロジックを採用しました。

というわけで結局「なければ自分で作ってみればいいじゃないか」の精神で自分で作って動かしてみることにしました。

様々なユースケースを想定した VAD の設計

背景にも書いたように、VAD の利用用途の一つとしてアクティブな状態における音声データを Whisper の WebAPI などの外部に投げることも想定しています。

様々なユースケースを考えると、音声データの入力も出力も自由度が必要ですし、VAD のロジックも好きにカスタマイズができても良いと考えています。

そのため、VAD の設計として大きく三つのモジュールの構成にしています。

  1. 音声入力ロジック -> IVoiceSource
    • 音声をどこから拾うか決める部分
    • Unity 標準のマイク機能 (UnityEngine.Microphone)、Unity 内の音声 (AudioSource)、OS から直接マイク入力を拾う、音声ライブラリを使用する、などの選択肢があります
  2. 音声検出 (VAD) ロジック -> IVoiceActivityDetector
    • Source から受け取った音声データを利用して、VAD の判定を実際に行う部分
    • 簡易的なデフォルト実装を用意していますが、必要なら他のロジックを持ってきて利用することも可能です
  3. 音声データの利用ロジック -> IVoiceBuffer
    • VAD ロジックでアクティブと判定された区間の音声データを受け取って、何か処理をする部分
    • 必要がなければデータの利用はしなくてもいいですし、AudioClip に流し込むこと、WAV ファイルなどに書き込むことも想定できます

モジュール単位で抽象化を行い、利用者が任意に選択・カスタマイズができるような仕組みにしています。

この構成が分かればソースコードやサンプルの見通しも立つかと思います。

それぞれのパート別にもう少し掘り下げて紹介していきます。

音声入力ロジック

基本的な音声入力ソースとして Unity 標準のマイク機能 (UnityEngine.Microphone) が考えられます。

Unity標準のマイク機能といっても直接受け取れるのは AudioClip で、そこから音声データを取得する実装方針は大きく二つあります。

  1. AudioSource で再生 -> OnAudioFilterRead イベントで受け取る
  2. AudioClip.GetData で直接抜き取る

1 の方針は AudioSourceAudioMixer を仲介する際にエフェクトをかけたり(Low-pass filter や音量調整など)できるメリットもありますが、一度音声再生のルートを通る分遅延も大きめに発生します。

2 の方針はそれに比べると直接的ですが、AudioClip のデータをループさせて録音していることを考慮しながらデータの読み取りをする点に注意が必要になります。

参考:

qiita.com

今回は 2 の AudioClip から直接データを抜き取る方針を採用しています。

github.com

実装では UnityMicrophoneProxy というクラスをわざわざ中間に噛ませています。

これは UnityEngine.Microphone でマイク入力の AudioClip を何度も取得すると古いものが更新されなくなるため、複数がマイクを利用しようとした場合に競合しやすいためです。

なお、音声データの配列はそれなりにデータ量が大きいためメモリ使用量やアロケーションコストを考慮する必要がありますが、このライブラリでは ArrayPool を利用しています。

learn.microsoft.com

github.com

マイク以外の入力ソースとして Unity の内部で再生されている音声を利用する、つまり AudioSource から入力音声を取得する実装も一応用意しています。

github.com

他にも AudioClip から直接引っ張る方式やネイティブのマイク入力を拾う方式などもアイデアとして考えられますが、実装するかは要望次第です。

VAD ロジックの検討

VAD のロジック本体ですが、現在は 2 パターンを用意しています。

  • QueueingVoiceActivityDetector
    • 一定時間分の Queue に溜めつつ、Queue 内部の発話時間の割合で VAD を判定するロジック
    • メモリ使用量抑えめ、挙動は少し不安定
  • CumulativeVoiceActivityDetector
    • 発話している時はゲージが増え、発話していない時間はゲージが減り、ゲージがなくなったら非アクティブになるイメージの VAD ロジック
    • メモリ使用量多め、挙動は安定め

当初は前者のメモリに配慮した実装を使用していましたが、非アクティブ -> アクティブに切り替わるタイムラグ分の音声の処理で結局メモリを使ったり、二回目以降の VAD が少し不安定な様子があったため後者のロジックを追加しました。

現在は後者のロジックを推奨しています。

Whisper で利用することを想定してロジックを組む場合には気を付けるべき点がいくつかあります。

  1. 短すぎる音声を使わないこと
  2. 無音の音声を使わないこと
  3. 一度に処理できる音声の長さ(コンテキストサイズ)が 30 秒であること

1 や 2 は empty が返ってきたり Whisper モデル自体が持つ Hallucination を起こして「字幕視聴...」のような謎の結果を出力したり特定の単語を何度も繰り返したりする現象をなるべく防ぐためです。

参考:

Whisper 論文

arxiv.org

6. Limitations and Future Work / Improved decoding strategies.

の部分に Hallucination のコメントがあります。

音声データの利用ロジック

VAD で切り取ったアクティブな判定の音声データの利用方法はいくつか考えられます。

基本的に想定できるユースケースの実装は用意していますが、欲しいものがない場合には自分で IVoiceBuffer の実装を用意して利用することもできます。

サンプル

使用例の一つとして、UnityEngine.Microphone から取得した音声を使って VAD を行い、AudioSource で再生するサンプルを簡単に紹介します。

github.com

まずマイク入力を利用する IVoiceSource をセットアップします。

proxy = new UnityMicrophoneProxy();
IVoiceSource source = new UnityMicrophoneSource(proxy);

次にアクティブな音声を AudioClip に流す IVoiceBuffer をセットアップします。

var buffer = new AudioClipBuffer(
    maxSampleLength: (int)(parameters.MaxActiveDurationSeconds * source.SamplingRate),
    frequency: source.SamplingRate);

そして VAD のロジックとパラメータを指定して IVoiceActivityDetector をセットアップします。

vad = new QueueingVoiceActivityDetector(
    source,
    buffer,
    parameters.MaxQueueingTimeSeconds,
    parameters.MinQueueingTimeSeconds,
    parameters.ActiveVolumeThreshold,
    parameters.ActivationRateThreshold,
    parameters.InactivationRateThreshold,
    parameters.ActivationIntervalSeconds,
    parameters.InactivationIntervalSeconds,
    parameters.MaxActiveDurationSeconds);

生成される AudioClipAudioClipBuffer.OnVoiceInactive のイベントで取得できるので、これを AudioSource にセットして再生します。

buffer
    .OnVoiceInactive
    .Subscribe(clip =>
    {
        Log.Info("[VAD.Sample] OnInactive and receive AudioClip and play.");
        audioSource.clip = clip;
        audioSource.Play();
    })
    .AddTo(this);

MonoBehaviour のライフサイクルに合わせて更新処理、破棄処理も忘れないよう叩いておきます。

private void OnDestroy()
{
    vad?.Dispose();
    proxy?.Dispose();
}

private void Update()
{
    vad?.Update();
}

組み合わせのパターンが多いためすぐに利用可能なコンポーネントは用意していませんが、どの Source / Logic / Buffer を使うのかユースケースに合わせてサンプルを見ながら実装するのはそこまで難しくないかと思います。

まとめ

自作の Unity 向け VAD ライブラリ voice-activity-detection-unity を紹介しました。

ユースケースに合わせて Source / Logic / Buffer を選択・カスタマイズして利用することができます。

Unity のマイクや音声データの取り扱いで気を遣う点や音声データを Whisper で利用する場合に注意すべき点などの細かな工夫もいくつか紹介しました。

おわりに

Unity での音声データの取り扱いに慣れてさえいれば実装自体はそこまで複雑ではありませんでした。

主な動機であった Whisper との連携も実際にしばらく使用してみて、Cumulative のロジックの方では実用レベルで安定することも確認できました。

とはいえやや急ぎ気味に作ったライブラリでもあるのでまだまだチューニングの余地があったり、VAD のロジックもより良い実装があると思います。

他にも WebRTC の VAD や最近の機械学習ベースの VAD など高度な実装も世の中にはありますので、処理負荷と精度、安定性がどんなレベルなのか気になります。

音声入力を VAD でコントロールして、ローカルにせよ API にせよ常に Whisper を使い続けることを避けられますので、会話ができる AI システムなど音声入力ベースのシステムなどにも便利に利用できるかと思います。

Unity で VAD を単体で利用したい場合に参考になれば幸いです。

*1:コスト面だけなら Local で Whisper を動かすという選択肢もありますが、音声をどこで区切るべきかという問題は残ります

*2:Whisper の API は一度にアップロード可能なファイルサイズが25MBの制限もあります

VTuber向けソフトウェア"Warudo"を試してみた

はじめに

こんにちは、エンジニアのクロ(@kro96_xr)です。

先日"Warudo"というVTuber向けソフトウェアを知り、これは凄いと思ったので記事にしてみます。

2023年9月15日現在WarudoはSteamからダウンロードでき、個人利用であれば完全無料で使用で出来ます。
残念ながら日本語には対応していませんが、UIもわかりやすくある程度英語が読めれば使うことが出来ると思います。

store.steampowered.com

今回の記事では執筆時点で最新のバージョン0.10.13を使用しています。

セットアップ

アプリケーションを立ち上げると以下のようなメニューが表示されるので、さっそくBasic Setupから進めていきます。

使用できるモデルはVRM(.vrm)、もしくはWarudo専用フォーマット(.warudo)のようです。
画面の指示通りvrmモデルをフォルダにいれると、そのモデルを選択できるようになります。

するとモーキャプのセットアップのサポートが必要かどうか聞かれます。個人で手軽に使いたい場合には嬉しいUXですね。
せっかくなので使ってみます。

すると以下の4項目について質問されます。

  • フルボディトラッキングが必要か
  • 上半身のトラッキングが必要か
  • FaceIDをサポートしているiPhoneかiPadを持っているか
  • ウェブカメラを持っているか

フルボディトラッキングに関してYesにするとさらに細かい設定が出てきます。
色々な環境に対応していそうですが、自宅では試すことが出来ないので今回は設定しません。

次に、フェイストラッキングと姿勢トラッキングに使用するアプリを提案してくれます。 私は普段iFacialMocap + VMagicMirrorという環境でやっているのでその設定にします。

トラッキング設定が完了すると、表情をインポートするか聞かれます。
VRM作成時に設定済なのでそのままインポートします。

次に、背景などの環境設定について聞かれます。こちらもwarudoファイル 今回はデフォルトで用意されているVR Roomを使ってみます。

これで設定完了です。
質問に答えていくだけでセットアップが出来るので簡単ですね!

最後にiFacialMocapの送信先IPを設定して完了です!

はてなブログに動画を貼れなかったので画像になります。

その他いろいろ触ってみる

基本設定は上記で完了ですが、他にも色々出来そうだったので触ってみました。

キャラクターを追加する

左ペインの下にある+ボタンを押すとアセットの追加が出来ます。

ここからキャラクターを追加して、モーキャプの設定をすると2人のキャラクターを別々の入力で動かすことができました。
スクショでは全くわからないと思いますが片方はiPhoneのiFacialMocapを、もう片方がWebカメラでMediaPipeを使っています。

3Dオブジェクトを追加する

キャラクター追加と同様に3Dモデルを追加することも出来ます。
さらに、他のオブジェクトにアタッチすることもできるので、ペンギンを頭に載せて追従させたりもできます。

スクリーンを追加してデスクトップやブラウザを映すことも出来ます。

モーションレコーダー&モーションプレイヤー

モーションのレコーディング機能もありました。

wanim, fbx, bvhといった形式でも出力できるようです。

wanim形式で出力したデータはそのままWarudo内のプレイヤーでも使用できるようです。

各種配信サービスとのインタラクション

Youtube, Twitch, Bilibiliのアカウントを持っている場合、コメントなどに対応したアクションを行うことが可能です。

デフォルトではオブジェクトやスタンプがキャラクターに向かってくる設定になっています。

また、この設定はノードベースで設定されているため、自分でパイプラインを構築することも可能そうです。

ユーザーアセットの導入

Discoverというタブからはユーザーがアップロードしたアイテムを使用することが出来ます。
ここには3Dオブジェクトだけでなく、空間自体やプラグインなども公開されており、色々活用が出来そうです。
ただ、権利周りが気になりますが…

おわりに

以上、Warudoについて色々触ってみた結果をスクショ多めでお送りしました。
正直言って無料で使わせてもらっていいのかというくらい多機能で様々な用途で使えそうだなと思いました。
個人勢の皆様ぜひ一度触ってみてはいかがでしょうか?

ちなみに商用利用は問い合わせが必要とのことです。

Unity Entitiesが正式リリースされたのでマルチプレイチュートリアルを弄ってみる(その2)

前回の記事ではNetCode for Entitiesを触り、シンクライアントの挙動を実装する所までコードを書きました。 前回のプロジェクトに引き続き改造を加えてみます。

ビルドして実行してみる

前回、シンクライアントの挙動を実装しましたが、実際にマルチプレイが動作するか確認したい為、ビルドを行います。

サーバーのビルドは、ビルドプラットフォームをDedicated Serverにする事で可能です。

ただし、クライアント向けビルドに関しては、そのままWindowsプラットフォーム等でビルド実行するとサーバー機能を内包したホストクライアントとしてビルドされてしまうようです。

これを回避し、簡単に動作確認したいのであればScripting Define SymbolsにUNITY_CLIENTを定義すると完全なクライアントとしてビルドすることができます。

より細かく制御したい場合は、ClientServerBootstrapを継承しているGame.csをカスタマイズし、手動でCreateClientWorld()CreateServerWorld()を呼び出す事でサーバー/クライアントの処理分岐が実装可能です。

サンプルの実装ではデフォルトで同じPC上にあるサーバープログラムに接続しに行くので、サーバービルドとクライアントビルドを並べて起動することで簡単にマルチプレイの動作確認が行えます。

他のコンピュータに接続したい場合も、Game.csをカスタマイズしてDefaultConnectAddress を指定してやれば制御可能です。

キューブが移動方向を向くように変更してみる

これで最低限のマルチプレイが動作する事は確認できたので、ここから色々書き換えて少しづつゲームっぽくしていきます。

PlayerMovementSystemOnUpdateの中に、以下のように、何かしらの移動入力がされていたらその向きを向くようにコードを追加してみます。

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
    var speed = SystemAPI.Time.DeltaTime * 4;
    var rotateSpeed = SystemAPI.Time.DeltaTime * 720;
    foreach (var (input, trans)
             in SystemAPI.Query<RefRO<PlayerInput>, RefRW<LocalTransform>>().WithAll<Simulate>())
    {
        var moveInput = new float2(input.ValueRO.Horizontal, input.ValueRO.Vertical);
        moveInput = math.normalizesafe(moveInput) * speed;
        trans.ValueRW.Position += new float3(moveInput.x, 0, moveInput.y);
        
        if (math.lengthsq(moveInput) > 0)
        {
            trans.ValueRW.Rotation =
                Quaternion.RotateTowards(
                    trans.ValueRO.Rotation,
                    quaternion.LookRotationSafe(new float3(moveInput.x, 0, moveInput.y), math.up()),
                    rotateSpeed);
        }
    }
}

Transformの制御周りのAPIはかなり充実しており、普通のUnityに近いノリで書ける印象です。

キューブをキャラクターに変更してみる

キューブのままでは味気ないので、見た目をSpace Robot Kyleに変更してみました。 こちらはUnity AssetStoreよりダウンロードできます。

assetstore.unity.com

Entities Graphicsパッケージの最新版は、実験的機能ながらSkinned Meshにも対応しているため、キューブと同様の手順で簡単にセットアップできます。

今のところHumanoid Animationには対応していないので棒立ちのままですが、マルチプレイで複数のキャラクターが移動する絵が出来ました。

今回はここまで

次回はキャラクターアニメーションの実装にチャレンジする予定です。

ただし、きちんとした実装は以下の様なアセットが既にあるので、テックブログの内容としてはアニメーションを動かす為のボーンや階層構造の話が主になるかと思います。

assetstore.unity.com

正式リリースされたEntitiesは、数々の強力なAPIにより最適化された複雑な処理を簡単に書けるようになってきていますが、一方で公式からサポートされている範囲はまだ狭く、その外側に踏み込むには覚悟が必要です。

勉強の為にもこれからいくつかの機能実装に挑戦してブログに載せていきたいと考えていますが、実プロダクトの観点では、Entitiesを導入する前に、今のEntitiesで目的となる表現が実現出来るかどうかをきちんと見極める必要がありそうです。

RustでVectorDBを触りたい?!

こんにちは、エンジニアの渡辺(@mochi_neko_7)です。

今回は Rust で VectorDB を触ってみる話をします。

VectorDB は機械学習の文脈で使われることが多いため Python のサポートをしているものが多い一方で、Rust で直接触れるものが本当に少ないです。

なぜわざわざ Rust で使用したいのかの背景もお話ししつつ、Rust で触れる VectorDB である Qdrant、 追加で必要になる Sentence Embeddings など実装する上で考慮すべきポイントもサンプルコードを交えながら紹介します。

かなりニッチなテーマになりますが LLM の文脈で Rust でサーバーを構築したい場合などにヒントになるかもしれません。

環境

本記事の検証で使用した環境は下記になります。

  • Windows 11
  • NVIDIA GeForce RTX 4090
  • NVIDIA Driver 536.23
  • CUDA 11.8.0
  • cuDNN 8
  • libtorch 2.0.1
  • Docker Desktop 4.19.0
  • Rust 1.71.1
  • Qdrant 1.4.1
  • rust-bert 0.21.0

github.com

背景

VectorDB(ベクトルデータベース)とは何かの説明は下記をご覧ください。

www.octoparse.jp

生成AI などの機械学習の文脈ではテキストや画像などのデータをベクトルに変換し、 ベクトル同士の類似度(距離や角度)を比較して検索することに特化したデータベースを利用します。

VectorDB を提供しているサービスは OSS も含めて様々ありますので、用途に合わせて選択することになります。

冒頭でも話したように VectorDB は機械学習の文脈で使われることが多いため、 ほとんどの VectorDB が Python で手軽に触れるよう整備されています。

例えば In-memory でサクッと使えるものだと ChromaDB などがあります。

ただし Python はプロトタイピングには困らない一方で実際のプロダクトとして複数人での保守運用も考えると、 Python ではなく Golang や Rust などの静的型付けのある言語で制約をつけながら開発をしたいという考え方もあります。

Python での使用例は既に数多くありますので、今回は後者、特に Rust を使用することを考えてみます。

ちなみに後で紹介する rust-bert という Python の Transformers のようなライブラリが出ていたり、 HuggingFace も Rust 向けの ML ライブラリ を開発していたりと Rust で ML の流れは少し垣間見えます。

VectorDB: Qdrant

2023/08/22 現在では Rust で触れる VectorDB は調べても下記くらいしか見つかりません。

Qdrant は本体も Rust 製ですね。

他の選択肢として、REST API や gRPC などの WebAPI が提供されている VectorDB なら クライアントを自分で実装すれば使用することは可能です。

今回は Qdrant を使用して検証してみます。

github.com

Sentence Embeddings: rust-bert

いざ Qdrant を使ってみようとドキュメントや API を見てみると、 データベースへのデータの登録はベクトルを生で受け取る API になっていることが分かります。

qdrant.tech

汎用的な VectorDB としては確かに十分ですが、 現実的にはテキストや画像などのデータをベクトル(Embeddings)に変換する必要があります。

他の VectorDB、例えば先に紹介した ChromaDB だとテキストの Embedding 処理が組み込みで提供されていますが、 どうやら Qdrant では自前で実装する必要があるようです。

公式のチュートリアルには Mightly というサービスを利用する例が紹介されています。

qdrant.tech

ただし Mightly は商用利用は有償のサービスのようなので注意が必要です。

OpenAI の Embeddings API を利用する手もありますが、こちらも従量課金制です。

platform.openai.com

他に選択肢がないか調べていたところ、次の記事で rust-bert という Rust で Transformer ベースの機械学習モデルが触れるライブラリがあることを知りました。

qiita.com

examples を覗いてみると Sentence Embeddings のサンプルがあるじゃないですか。

github.com

今回は rust-bert を使用してテキストをベクトルに変換する Sentence Embeddings をローカルで走らせてみましょう。

VectorDB 利用の流れ

今回の検証で行うことを整理します。

  • Rust で VectorDB を利用する
  • VectorDB は Qdrant を、クライアントは公式のものを利用する
  • テキストデータを扱う
  • Sentence Embeddings は rust-bert で HuggingFace のモデルを使用する

VectorDB の基本的な操作は次の2つです。

  1. データの登録(upsert)
  2. データの検索(search)

1. データの登録(upsert)

登録したいテキストデータを Sentence Embeddings モデルを利用してベクトルに変換し、 メタデータ(payload)と一緒にデータベースに登録(upsert)します。

2. データの検索(search)

検索したいテキスト(query)を Sentence Embeddings モデルを利用してベクトルに変換し、 そのベクトルと類似しているデータを検索(search)します。

結果は Vec<ScoredPoint> というスコア付きのデータで受け取ることができ、 メタデータ(payload)も含まれています。

メタデータは自由に定義することができるので、例えば元のテキストを残しておいたり、 日時などの付加情報を載せることもできます。

サンプルコード

概念の説明だけだと味気ないのでサンプルコードも見ていきましょう。

github.com

1. 前提

サンプルコードは下記の前提で動くことを想定しています。

  • NVIDIA 製 GPU が利用できること
  • NVIDIA Driver がインストールされていること(CUDA 11.8 以上)
  • Docker が利用できること

これらのセットアップ手順は割愛します。

2. 環境構築

環境構築は Docker / Docker Compose で行っています。

rust-vector-db-demo/compose.yaml at main · mochi-neko/rust-vector-db-demo · GitHub

Rust の環境と別に、VectorDB の Qdrant のサービスを立てています。

Rust の環境は CUDA が使えるよう nvidia/cuda公式 Image をベースに、 Rust と libtorch を追加でインストールします。

rust-vector-db-demo/Docker/rust/Dockerfile at main · mochi-neko/rust-vector-db-demo · GitHub

CUDA、cuDNN、libtorch のバージョンは rust-bert が 利用している tch-rs の要件に合わせる必要がありますが、 今回は他で環境構築をしていたものを流用した関係で

  • CUDA 11.8
  • cuDNN 8
  • libtorch 2.0.1

を使用してセットアップしています。

Docker Compose では GPU の利用のために deploy: のブロックの設定が必要になる点にご注意ください。

docs.docker.jp

3. 利用する crate

Rust では下記の crate を使用しています。

[dependencies]
anyhow = "1.0.72"
chrono = "0.4.26"
qdrant-client = "1.4.0"
rust-bert = "0.21.0"
tokio = { version = "1.32.0", features = ["rt-multi-thread"] }
uuid = "1.4.1"

rust-vector-db-demo/Cargo.toml at main · mochi-neko/rust-vector-db-demo · GitHub

Rust に慣れていない方向けに用途だけ簡単に説明します。

  • anyhow : エラーハンドリングライブラリ
  • chrono : 日時のライブラリ
  • qdrant-client : Qdrant の Rust クライアントライブラリ
  • rust-bert : Transformer ベースの自然言語処理ライブラリ
  • tokio : 非同期処理ライブラリ
  • uuid : UUID ライブラリ(VectorDB の ID 発行のため)

プロダクションだと tracing などの Logging ライブラリも使用しますが、今回は省略しています。

以降のソースコードは main.rs にまとめて記述しています。

rust-vector-db-demo/src/main.rs at main · mochi-neko/rust-vector-db-demo · GitHub

4. Sentence Embeddings のモデルのセットアップ

Sentence Embeddings のモデルのセットアップは rust-bert のサンプルを参考に下記のコードで行っています。

    // Setup sentence embeddings model
    // NOTE: Run blocking operation in task::spawn_blocking
    let model = task::spawn_blocking(move || {
        SentenceEmbeddingsBuilder::remote(
            SentenceEmbeddingsModelType::AllMiniLmL6V2,
        )
        .create_model()
    })
    .await??;

    // Get embedding dimension
    let dimension = model.get_embedding_dim()? as u64;

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

使用するモデルの種類は SentenceEmbeddingsModelType の enum から選択できます。

rust-bert/src/pipelines/sentence_embeddings/resources.rs at fd1e66b1c7a0492d0a3f36feb9528c353214bbbc · guillaume-be/rust-bert · GitHub

tokio を使って非同期 main 関数内でモデルのセットアップをする場合は、tokio::task::spawn_blocking() を使って create_model() の内部のブロッキング操作を非同期のタスクとして分離しないと 実行時に panic になることに注意が必要です。

実際にはここでモデルのダウンロード処理が入るため、時間がかかります。

Qdrant の Collection 作成時にベクトルの次元数を指定する必要があるので、get_embedding_dim() で取得しておきます。

5. Qdrant のセットアップ

次に Qdrant のクライアントと Colletion をセットアップします。

    // Setup Qdrant client
    let qdrant = QdrantClient::from_url("http://qdrant:6334").build()?;
    qdrant.health_check().await?;

    // Create collection
    let collection_name = "collection_name".to_string();
    qdrant
        .delete_collection(collection_name.clone())
        .await?;
    qdrant
        .create_collection(&CreateCollection {
            collection_name: collection_name.clone(),
            vectors_config: Some(VectorsConfig {
                config: Some(Config::Params(VectorParams {
                    size: dimension,
                    distance: Distance::Cosine.into(),
                    ..Default::default()
                })),
            }),
            ..Default::default()
        })
        .await?;

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

Qdrant の URL http://qdrant:6334 のホスト名は今回は Docker Compose のサービス名 qdrant で指定しています。

その後、Collection を作成しています。

Colletion はデータベースのテーブルのような概念です。

qdrant.tech

size: dimension, でベクトルの次元数を、 distance: Distance::Cosine.into(), で距離の判定ロジックをコサインに指定しています。

6. VectorDB の基本操作の実装

具体的な処理をする前に、VectorDB の基本操作の実装を確認します。

テキストをベクトル(Embeddings)に変換するには、SentenceEmbeddingsModel.encode() を使用します。

fn embed(
    model: &SentenceEmbeddingsModel,
    sentence: &String,
) -> Result<Vec<Vec<f32>>> {
    Ok(model.encode(&[sentence])?)
}

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

データ登録の操作では、テキストをベクトルに変換してから PointStruct に整形してデータを登録します。

その際、Payload というデータにメタデータを載せることができるため、ここでは下記のデータを追加しています。

  • 元のテキスト
  • 作成した日時
async fn upsert(
    client: &QdrantClient,
    collection_name: &str,
    model: &SentenceEmbeddingsModel,
    text: &String,
) -> Result<()> {
    let embedding = embed(model, text)?;
    let mut points = Vec::new();
    let mut payload: HashMap<String, Value> = HashMap::new();
    payload.insert(
        "text".to_string(),
        Value::from(text.clone()),
    );
    payload.insert(
        "datetime".to_string(),
        Value::from(
            chrono::Utc::now()
                .format("%Y-%m-%dT%H:%M:%S%.3f")
                .to_string(),
        ),
    );

    for vector in embedding {
        let point = PointStruct::new(
            uuid::Uuid::new_v4().to_string(),
            vector,
            Payload::new_from_hashmap(payload.clone()),
        );
        points.push(point);
    }

    client
        .upsert_points(
            collection_name.to_string(),
            points,
            None,
        )
        .await?;

    Ok(())
}

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

データ検索の操作では、検索したいテキスト query をベクトルに変換してから search_points の API を叩いています。

オプションの count_limit: u64, で検索数の最大数を、filter: Option<Filter>, でフィルタを指定できます。

フィルタは例えば Payload に属性や Tag、日時などを仕込んで SQL の WHERE のように使用することもできます。

async fn search(
    client: &QdrantClient,
    collection_name: &str,
    model: &SentenceEmbeddingsModel,
    query: String,
    count_limit: u64,
    filter: Option<Filter>,
) -> Result<Vec<ScoredPoint>> {
    let embedding = embed(model, &query)?;
    let vector = embedding[0].clone();

    let result = client
        .search_points(&SearchPoints {
            collection_name: collection_name.to_string(),
            vector,
            limit: count_limit,
            filter,
            with_payload: Some(true.into()),
            with_vectors: Some(true.into()),
            ..Default::default()
        })
        .await?;

    Ok(result.result)
}

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

7. サンプルの実行

Sentence-Transformers の Semantic Search のサンプルを借りて、実際に VectorDB を通して検索ができるか確認します。

www.sbert.net

まずはコーパスを VectorDB に登録します。

    // Store corpus in Vector DB
    let corpus = vec![
        "A man is eating food.",
        "A man is eating a piece of bread.",
        "The girl is carrying a baby.",
        "A man is riding a horse.",
        "A woman is playing violin.",
        "Two men pushed carts through the woods.",
        "A man is riding a white horse on an enclosed ground.",
        "A monkey is playing drums.",
        "A cheetah is running behind its prey.",
    ];
    for sentence in corpus {
        upsert(
            &qdrant,
            &collection_name,
            &model,
            &sentence.to_string(),
        )
        .await?;
        println!("Upserted: {}", sentence)
    }

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

近い意味のテキストを指定して検索をします。

    // Search for similar sentences
    let queries = vec![
        "A man is eating pasta.",
        "Someone in a gorilla costume is playing a set of drums.",
        "A cheetah chases prey on across a field.",
    ];
    for query in queries {
        let result = search(
            &qdrant,
            &collection_name,
            &model,
            query.to_string(),
            5,
            None,
        )
        .await?;

        println!("Query: {}", query);
        for point in result {
            println!(
                "Score: {}, Text: {}",
                point.score,
                point
                    .payload
                    .get("text")
                    .unwrap()
            );
        }
        println!();
    }

rust-vector-db-demo/src/main.rs at 459677a12dbb3542495a0288cb49d8f06926db0f · mochi-neko/rust-vector-db-demo · GitHub

8. 実行結果

Docker の rust サービス内の bash で下記コマンドを叩いて main.rs を実行します。

cargo run

コマンドラインに表示される結果は下記になりました。

Upserted: A man is eating food.
Upserted: A man is eating a piece of bread.
Upserted: The girl is carrying a baby.
Upserted: A man is riding a horse.
Upserted: A woman is playing violin.
Upserted: Two men pushed carts through the woods.
Upserted: A man is riding a white horse on an enclosed ground.
Upserted: A monkey is playing drums.
Upserted: A cheetah is running behind its prey.
Query: A man is eating pasta.
Score: 0.70354867, Text: "A man is eating food."
Score: 0.52719873, Text: "A man is eating a piece of bread."
Score: 0.18889551, Text: "A man is riding a horse."
Score: 0.10469921, Text: "A man is riding a white horse on an enclosed ground."
Score: 0.09803035, Text: "A cheetah is running behind its prey."
Query: Someone in a gorilla costume is playing a set of drums.
Score: 0.64325327, Text: "A monkey is playing drums."
Score: 0.25641555, Text: "A woman is playing violin."
Score: 0.13887261, Text: "A man is riding a horse."
Score: 0.11909151, Text: "A man is riding a white horse on an enclosed ground."
Score: 0.10798677, Text: "A cheetah is running behind its prey."
Query: A cheetah chases prey on across a field.
Score: 0.8253214, Text: "A cheetah is running behind its prey."
Score: 0.13989523, Text: "A man is eating food."
Score: 0.12919357, Text: "A monkey is playing drums."
Score: 0.10974166, Text: "A man is riding a white horse on an enclosed ground."
Score: 0.06497804, Text: "A man is riding a horse."

近い意味のテキストがスコア順に並んでいることが分かり、動作確認ができました。

おわりに

以上のサンプルコードで Rust でも Qdrant (VectorDB)と rust-bert(Sentence Embeddings)を使うことで ローカルで VectorDB を触ることができました。

有料の他サービスを活用すればもっと手軽に実装することもできるかと思います。

VectorDB の具体的な用途はやはり LLM 関連が多いと思いますが、 AI Agent や AITuber のサーバーを Rust で実装することも要件次第では可能です。

WebAPI サーバーとして機能を実装するなら REST だと axum、 gRPC なら tonic などと組み合わせると良いです。

rust-bert の依存関係の問題でついでに tch-rs も動かせる環境がセットアップできているので、 例えば Stable Diffusion など他の機械学習関連のモデルも Rust で動かすことができたりします。

もちろん Python だともっと簡単に実装できるものですが、それ以外の言語の選択肢があるということも大切かもしれません。

Rust 人口がまだ少なく、かつ Rust で機械学習、LLM、VectorDB を触りたいという人はおそらく極端に少ないためかなりニッチな話になると思いますが、 個人的には Python より Rust の方が開発も保守運用もしやすいと思っているので要件の合う機会がありそうなら現場でも活用できないかなと思っています。

React 18の新機能によるパフォーマンス改善について実際に動かして確かめる

こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。

ちょっと前の記事になりますが、Vercelより「How React 18 Improves Application Performance」という記事の投稿がありました。

vercel.com

React 18で導入された最新機能が、アプリケーションのパフォーマンスをどのように改善するかという内容です。

こちらの内容が興味深く、自分自身でも試してみることにしました。

概要

まず、記事の概要について説明します。自分で読んで理解したいよという方は元記事をご覧ください。

※間違った解釈をしている可能性があります。ご了承ください。

ざっくり分けるとこんか感じかと思います。

  1. Javascriptのシングルスレッドとロングタスクの問題
  2. Reactの従来のレンダリング手法
  3. React 18で導入された新機能による解決方法

1. Javascriptのシングルスレッドとロングタスクの問題

1つ目は、ブラウザでJavascriptを動かす場合、シングルスレッドで実行するため、時間のかかるタスクを実行すると終了していないタスクが待機状態になり、例えば、クリックやキーボード入力といったユーザーインタラクションが行えない、無反応という問題が発生します。

Tipsですが、この時間のかかるタスクのことをロングタスクと呼び、処理時間に50ms以上かかる場合、ロングタスクとなるそうです。

2. Reactの従来のレンダリング手法

2つ目は、Reactのレンダリングの説明で、Reactのレンダリングの更新には、RenderingフェーズとCommitフェーズの2つのフェーズがあります。

前者では、現在のDOMと変更箇所を比較し、必要な箇所だけ更新した新しいDOMを作成します。

後者では、新しく作成したDOMを実際のDOMに適用します。

このレンダリング手法と、先ほどのシングススレッドとロングタスク問題が大きく関わってきます。

コンポーネントの複雑さによってレンダリングに時間がかかると(=ロングタスク)、メインスレッドがブロックされ、新規のDOMがコミットされるまで、ユーザインタラクションが行えない、無反応になってしまうという問題です。

これは、ユーザエクスペリエンス的には最悪です。

3. React 18で導入された新機能による解決方法

上記の問題を解決する手段として、React 18のアップデートのうち、以下の機能が活用できるようです。

  • Transitions
  • React Server Components
  • Suspense
  • Data Fetching

こちらのうち、「Transitions」と「Data Fetching」について、次の章で、デモを通じて勉強していきます。

Transitions

React 18で追加されたuseTransitionを用いることで、優先度の低い(緊急でない)コンポーネントのレンダリングを後回しにし、それ以外のタスクを優先して実行することができます。

これを導入するメリットは、ロングタスクによるユーザインタラクションが行えないという問題を改善し、クリックやキーボード入力を先に実行し、その後にロングタスクを実行することができます。

具体的に、どういうことかをデモを通じて見ていきます。

デモの説明

元記事とほぼ同じですが、大量のGitHubのリポジトリ情報から、特定のキーワードでリポジトリ名を絞り込むというデモを作成しました。

大量のリポジトリ情報(JSON)は、以下のリポジトリからお借りしました。

github.com

ソースコード

Transitionsを適用するのは非常に簡単で、startTransition関数で、優先度の低い処理囲ってあげるだけでOKです。

// ...
export default function Children() {
  const [text, setText] = useState("");
  const [keyword, setKeyword] = useState(text);
  const [isPending, startTransition] = useTransition();

   return (    
      <main>      
          <input  
              type="text" 
              value={text}
              onChange={(e) => {
                setText(e.target.value)
                startTransition(() => { // ここ
                  setKeyword(e.target.value)
                })
             }}  />      
          <List keyword={keyword} />    
      </main>  
     );
};

全ソースはこちら

react-18-performance-demo/src/app/children.tsx at transition · nandemo3/react-18-performance-demo · GitHub

動作検証

それでは、startTransitionを導入することによってどう変化があるかを、動作を見ながら確認していきます。

また、元記事にもありますが、検証する場合、Chromeの開発者ツールにて、Performanceタブを開いてCPUを「4× slowdown」にします。

これをしないとPCスペックでカバーされ、変化に気づきにくくなります。

デモ画面は簡素ですがこんな感じで、

テキストボックスにキーワードを入力すると、キーワードが含まれるリポジトリが表示されるというものです。

このデモは、startTransitionを使わないパターンで、「google」と入力されるまでに遅延があります。

この時、テキストボックスの値が更新されたためonChangeイベントが発火するわけですが、setKeywordでkeywordも更新れているのでListコンポーネントが再レンダリングされます。

Listコンポーネントでは、keywordでJSONのデータをフィルタリングしており、

データが膨大なため処理に時間がかかり(=ロングタスク)、テキストボックスが無反応になっているということになります。

`startTransition`を使わないパターン

一方、こちらはstartTransitionを使っているパターンです。

「google」と入力すると1文字目まで入力されます。

これは、startTransition関数でkeywordの更新setKeywordが後回しになり、テキストボックスの値の更新setTextが優先されているため起きています。

`startTransition`を使うパターン

所感

startTransitionを導入前後で、大きな変化がないなと思いましたが、

このユーザの動作に一瞬反応するorしないは、ユーザエクスペリエンスにおいて、ユーザを混乱させない一つの要因になるので、

ロングタスクになりやすい箇所に保険で入れておくのは良いのではないかと思いました。

Data Fetching

続いて、Data Fetchingについて見ていきます。

React 18ではfetchを用いてAPIリクエストをすると、レスポンスをキャッシュするようになりました。

ただ、使用上の注意があり、React Server Componentであること(サーバサイドレンダリングであること)と、同一リクエストパラメータであることです。

私は、ちゃんと記事を読まずに、CSRでデモを作成し、全然キャッシュされず毎回APIリクエストを送っていたので、困惑しました。

それから、同一リクエストパラメータについてです。

元記事からソースコードを引用すると、fetchPost(1)が2回呼び出されていますが、このように同じ引数のリクエストの場合、2回目はキャッシュされたデータを返すということです。

export const fetchPost = (id) => {
  const res = await fetch(`https://.../posts/${id}`);
  const data = await res.json();
  return { post: data.post } 
}

fetchPost(1)
fetchPost(1) // Called within same render pass: returns memoized result.

デモの説明

実際にはどのように動くかをデモで検証していきます。

まずは、APIです。クエリパラメータnameに指定した値とレスポンス時間を返すだけの簡単なAPIを作りました。

$ curl 'http://localhost:3001/hello?name=hoge'
{"message":"Hello hoge","date":"2023-08-23T02:29:40.258Z"}

リクエスト時にログを出力します。

::ffff:127.0.0.1 - - [23/Aug/2023:05:14:18 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0"
::ffff:127.0.0.1 - - [23/Aug/2023:05:14:18 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0"
::ffff:127.0.0.1 - - [23/Aug/2023:05:14:21 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0"

フロントエンドでは、そのAPIを同じパラメータで、2回リクエストします。

このように、Request1とResuest2で結果が同じになっています。

ソースコード

// api.ts
export const getLocalApi = async(name: string) => {
  const res = await fetch(`http://localhost:3001/hello?name=${name}`);
  const data = await res.json();
  return data
}
// page.tsx
import { getLocalApi } from "./api"

export default async function Home() {
  const data1 = await getLocalApi("Kobayashi") // リクエスト1回目
  const data2 = await getLocalApi("Kobayashi") //リクエスト2回目

  return (
    <main>
      <div>
        <h3>Request1</h3>
        <div>{data1.message}</div>
        <div>{data1.date}</div>
      </div>
      <div>
        <h3>Request2</h3>
        <div>{data2.message}</div>
        <div>{data2.date}</div>
      </div>
    </main> 
  )
}

全ソースはこちら

GitHub - nandemo3/react-18-performance-demo at data-fetching

動作検証

上記の画面では、Request1とRequest2でどちらも同じ日時が表示されていましたが、本当にキャッシュされたデータなのか見ていきます。

fetch

ブラウザのキャッシュを削除するとAPIがリクエストされ、それ以降はキャッシュされたデータを表示しています。

ただ、初回のリクエストは2回ともAPIをコールしていることがわかります。

元記事を読む限り、2回目はキャッシュされるのでは?と思ったのですが、この検証を見る限り、初回レンダリングは全てAPIをコールするようです。

(設定が間違えているなどあるかもしれません。)

では、比較検証のため、Axiosを用いて、同じデモを行います。

// api.ts
import axios  from 'axios';

export const getAxiosLocalApi = async(name: string) => {
  const res = await axios.get(`http://localhost:3001/hello?name=${name}`);
  const data = res.data;
  return data
}

レンダリングのたびにAPIがリクエストされ、レスポンス時間も更新されていることがわかります。

axios

やはり、fetchは同一リクエストをキャッシュすることが分かります。

所感

同一リクエストでもレスポンス時間を取得したいというニーズがあった時、

fechだとキャッシュしたデータが返ってきてしまうので、こういったときはAxiosを使うなどの切り分けが必要になりそうだなと思いました。

ただ、同一リクエストをサーバサイドで行うというケースは、静的データの取得かもしれないので、良いのかもしれません。(そんなことないと思う・・・)

まとめ

React 18の新機能によるパフォーマンス改善について、Vercelの記事を元に調べて見ました。

こういった記事が定期的に投稿されると最新動向が追いやすいため非常にありがたいですね。

また、フロントエンドにおけるパフォーマンスというのは、ユーザ体験の向上という目的が大きいなと思いました。

時間のかかる処理は仕方ないとし、まずユーザインタラクションを優先して処理するという強い意図を感じました。

ここでは触れていませんが、パフォーマンス改善に関しては、Suspenseが最も強力な機能だなと思いました。

最後まで読んでいただきありがとうございました。

librgを使って仮想空間上にいる他の人を検索してみる

はじめに

エンジニアの松原です。最近リアルタイムネットワークに関連するフレームワークやプロトコルについて調べていますが、リアルタイム通信に関連してオンラインゲームの仕組みを調べることもあります。今回の記事はオンラインゲームに関連した調査の中で見つかった内容を記事にしました。
今回取り上げるのは仮想空間上に存在するユーザーをデータベースのように検索する方法になります。

ユーザーが多数存在する空間内でのユーザー間通信は難しい

実際にオンラインゲームをやったことがある方には想像がしやすいと思うのですが、オンラインゲームでは他の参加プレイヤーと遊ぶため、プレイヤー間での通信処理が行われます。
ただし、そのオンラインゲームの全員参加者と常に通信処理が発生しているわけではありません。以下にオンラインゲームのサーバー構成と参加プレイヤーを抽象化したパターンを2つほど紹介します。

セッション参加型ゲームのゲームサーバーについて

1回のゲームをセッションとして作成し、そのセッションにプレイヤーを割り当て、ゲームを開始し、ゲームが終了したらプレイヤーを開放してセッションを終了するという方式があります。
この方式では、実際にゲームを実施するゲームサーバー(セッションサーバー)とゲーム募集のプレイヤーを集めるロビーサーバーという構成で分かれています。

この方式ではゲームロビーは大人数を収容しやすいようにシンプルに作られ、プレイヤーの存在は抽象化されているため、物理的な位置情報はプレイヤーに存在せず、プレイヤー間のコミュニケーションも最低限に作られているケースが多いかと思います。
アクションゲームを例にした場合、セッションサーバーではプレイヤーは物理的な位置情報を持ち、少人数~中人数のユーザー間でリッチな通信処理が行われます。いくつか例外のゲームがあるとはいえ、対戦型であれば1回のゲームでの参加プレイヤー数は多くても2,30人ぐらいに収まるかと思います。

MMORPGでのゲームサーバーについて

MMORPGの場合、ゲーム空間内で非常に多くのプレイヤーを抱え、ほとんどすべての状況でプレイヤーには物理的な位置情報が与えられ、また「1回のゲーム」という概念がないため、セッション参加型のようなサーバー構成が持てません。このため別のアプローチから多人数のプレイヤー間の通信量を削減する仕組みが必要になります。

MMORPGではマップやワールド、エリアという概念でプレイヤーの集団を分割し、同様の構成を持つ複数のサーバーを分散管理・運用する方式が主体になっています。これによって、ゲームサーバーを跨ってもそれぞれのプレイヤーと物理的な位置の待ち合わせができ、ある程度クライアントサーバー間の通信量を減らす仕組みを持つことができます。

ただし、サーバーが抱えるエリアやマップが広大かつ、そのエリアに参加しているユーザーが多い場合、各々のプレイヤーがエリアにいる全プレイヤーと通信を行うと通信量が膨大になるため、プレイヤー毎に影響がない通信を極力絞る必要があります。

以下の図ように、特定のプレイヤーからは見えない他プレイヤーとの通信を減らすアイデアが思いつきますが、これを実際に実現するためには、しっかりとした設計を持たないとシステムが非常に複雑になり、却ってサーバー負荷を増やす恐れがあります。

上記の仕組みに関して、効率的な処理を行えるように設計、実装されたライブラリとして librg があります。今回はこのライブラリを使って、仮想空間内にプレイヤーが多数いる場合を想定して、ゲームとして成立させるため必要となる他プレイヤーや要素を抽出する仕組みについて考えていきたいと思います。

github.com

librgを使ってみる

ここからは実際にlibrgを使ったコードを追いつつ、図解も併せてlibrgで何を行っているか説明していきます。
librgではxyzの3次元空間の表現ができますが、1次元や2次元での表現も可能です。今回は2次元空間をイメージしてコードを書きました。

以下で登場するコードはGitHubにアップロードしたサンプルコードに含まれていますので、以下のサンプルコードを見つつ本記事を見ると理解しやすいかと思います。ソースコードは src/main.cpp になります。
librg.h が別途必要になるのですが、 docker-compose build 実行後に docker-compose run --rm --entrypoint "./build.sh" app を実行すると自動的にダウンロードされます。

github.com

worldとチャンクの設定を行う

まずはワールドを作成し、そのワールドに対し、チャンクサイズとチャンクアマウント(チャンクのタイル数)を設定します。これはエリアやマップのような仮想空間の構造と広さを設定することになります。

今回のワールドは2次元空間として設定するため、各チャンクの大きさは16x16(メートル換算)の大きさに設定します。チャンクをタイル状に並べ、縦横16x16の計64個チャンクを持つように設定しています。
最後の行の librg_config_chunkoffset_set() の関数は、タイル状に並んだチャンクの中心位置を仮想空間内の座標(0, 0, 0)に一致させるため、チャンクの位置のオフセットを行っています。
図では見やすさのため8x8のタイルで表現していますが、実際には16x16のタイルを持っていると頭の中で置き換えてください。

    librg_world *world = librg_world_create();
    /* (略) */
    const int chunksize = 16;
    const int cuunkamount = 16;

    librg_config_chunksize_set(world, chunksize, chunksize, 1);
    librg_config_chunkamount_set(world, cuunkamount, cuunkamount, 1);
    librg_config_chunkoffset_set(world, LIBRG_OFFSET_MID, LIBRG_OFFSET_MID, LIBRG_OFFSET_MID);

自分自身を表すエンティティの作成とチャンクへの追加

ワールドの作成が終わったら、エンティティを追加します。このエンティティはプレイヤーの位置を表す駒とみなすことができます。また、librgではエンティティに所有権(=プレイヤー的存在)を持たせることができます。
最初にエンティティの追加を行い、そのエンティティをプレイヤーとしてみなす必要があれば所有権の付与を行います。エンティティはIDで管理され、所有権に関してはオーナーID(=プレイヤーID)をそのエンティティに関連付ける形で設定を行います。

注意しないといけないのは、librgではエンティティIDは重複して作成できません。またエンティティの自動採番の仕組みもありません。さらにエンティティに対してオーナーの割り当ては簡単に上書きできてしまうので、librgだけでエンティティの管理は難しく、外部にデータベースを持つなどしてエンティティの採番状況を管理をするための仕組みが必要になります。

エンティティはまずワールドに登録(track)し、オーナーIDを割り当て、チャンクにセットします。座標からチャンクを割り出すときは、 librg_chunk_from_realpos() の関数を利用することでセットする対象のチャンクを取得することができます。

    double init_pos_x = 8.0;
    double init_pos_y = 8.0;
    double init_pos_z = 0.0;

    int64_t own_entity_id = 1;
    int64_t owner_id = 1;

    librg_chunk initial_chunk = librg_chunk_from_realpos(
        world,
        init_pos_x,
        init_pos_y,
        init_pos_z
    );
    /* (略) */
    librg_entity_track(world, own_entity_id);
    librg_entity_owner_set(world, own_entity_id, owner_id);
    librg_entity_chunk_set(world, own_entity_id, initial_chunk);

ほかのエンティティを追加する

自分が所有していないエンティティを追加します。これらのエンティティは他のプレイヤーやNPCを想定しています。
librgはチャンク単位での近傍は計算してくれますが、座標単位の情報は持っていないので、エンティティ自身の詳細な座標情報も別のデータベースシステムで管理するのがよさそうです。
エンティティを見分けるため、以下のコードではエンティティのIDが10000から始まるようにオフセットを設定しています。
rd_dist(rd_engine) の関数には-128~128の範囲の値を持つ倍精度の浮動小数点数を生成する仕組みを持たせています。

    int entity_sum = 10000;
    double buf_pos_x[1024];
    double buf_pos_y[1024];

    for(int i = 0; i < 1024; i++) {
        double pos_x = rd_dist(rd_engine);
        double pos_y = rd_dist(rd_engine);
        double pos_z = 0.0;
        buf_pos_x[i] = pos_x;
        buf_pos_y[i] = pos_y;

        int64_t entity_id = i + entity_sum;
        librg_chunk chunk = librg_chunk_from_realpos(world, pos_x, pos_y, pos_z);
        librg_entity_track(world, entity_id);
        librg_entity_chunk_set(world, entity_id, chunk);
    }

自分に近傍するエンティティを取得する

エンティティの設定が一通り終わったら、実際に自分に近傍する他のエンティティをlibrgの仕組みから取得してみます。
librgではオーナーIDから近傍するエンティティIDを取得することができます。以下のコードでは librg_world_query() のクエリ関数を使ってエンティティのIDリストを取得し、そのエンティティに対応する座標情報を外部のバッファから取り出しています。

自分の所有しているエンティティも返すようなので、コードではIDを比較して自分のエンティティを除外しています。
また、 target_radius は検索対象のチャンクの範囲を表す数値で、この数値が大きいほど広範囲にあるエンティティを取得します。

    uint8_t target_radius = 1;
    int64_t found_entity_ids[64];
    size_t found_entity_ids_size = array_length(found_entity_ids);

    librg_world_query(world, owner_id, target_radius, found_entity_ids, &found_entity_ids_size);
    /* (略) */
    for(int i = 0; i < found_entity_ids_size; i++)
    {
        int64_t entity_id = found_entity_ids[i];
        if (entity_id == own_entity_id) continue;

        int64_t buf_id = entity_id - entity_sum;
        double pos_x = buf_pos_x[buf_id];
        double pos_y = buf_pos_y[buf_id];
        /* (略) */
    }
    std::cout << std::endl;

サンプルコードの実行結果

サンプルコードを実行した結果は以下のようになります。
まず自分のエンティティを(8,8)の座標位置からチャンク136にセットしています。他のエンティティを作成後に、クエリ関数を使って自分のエンティティに近傍するほかのエンティティを取得しています。
query result(n=49) の行は計49個のエンティティを取得した、という結果になります。

次の行では以下の順序で情報をリスト化しています。

  • ヒットしたエンティティ(entity_id)
  • エンティティがセットされているチャンク番号(chunk_id)
  • エンティティの座標位置(pos)
  • 自分のエンティティからの距離(dist.)
...
A world is created

initial chunk_id:136, pos: (8.000, 8.000)

query result(n=49):
entity_id:10019, chunk_id:136, pos(-3.477, -12.139), dist.(-11.477, -20.139)
entity_id:10028, chunk_id:120, pos(12.208, -18.523), dist.(4.208, -26.523)
entity_id:10059, chunk_id:136, pos(-13.304, -7.018), dist.(-21.304, -15.018)
entity_id:10067, chunk_id:136, pos(-12.270, -0.455), dist.(-20.270, -8.455)
entity_id:10082, chunk_id:136, pos(15.558, 9.603), dist.(7.558, 1.603)
entity_id:10090, chunk_id:136, pos(13.876, 13.765), dist.(5.876, 5.765)
entity_id:10176, chunk_id:120, pos(-8.052, -17.825), dist.(-16.052, -25.825)
entity_id:10182, chunk_id:136, pos(11.255, 4.149), dist.(3.255, -3.851)
entity_id:10189, chunk_id:136, pos(-0.284, 14.554), dist.(-8.284, 6.554)
...

また、自分のエンティティの座標位置を動かし、別のチャンクに設定して再度クエリを実行した結果が以下のようになります。

...
updated chunk_id:170, pos: (32.000, 32.000)

query result(n=25):
entity_id:10058, chunk_id:171, (59.460, 45.238), dist.(27.460, 13.238)
entity_id:10098, chunk_id:186, (44.589, 48.039), dist.(12.589, 16.039)
entity_id:10282, chunk_id:154, (32.432, 22.841), dist.(0.432, -9.159)
entity_id:10317, chunk_id:170, (45.216, 36.754), dist.(13.216, 4.754)
entity_id:10319, chunk_id:154, (36.853, 19.680), dist.(4.853, -12.320)
entity_id:10349, chunk_id:169, (19.038, 36.099), dist.(-12.962, 4.099)
entity_id:10358, chunk_id:186, (34.620, 58.401), dist.(2.620, 26.401)
entity_id:10368, chunk_id:186, (38.868, 60.057), dist.(6.868, 28.057)
entity_id:10387, chunk_id:171, (52.941, 38.869), dist.(20.941, 6.869)
...

このように、仮想空間内で自分に近傍する他のプレイヤーや要素を抽出することができました。

おわりに

今回librgを使ってデータベースのように自分に近いプレイヤーや要素を抽出することができました。ただし、本当に効率的かどうかは今後パフォーマンス計測を行う必要がありそうです。
また、実際のサービスとして成立させるためには外部のデータベース(Redisなど)との連携方法、異なる設計を持つサーバーとの構成や通信方法、冗長構成を考えていく必要がありそうです。
ともあれ、librgはゲームサーバー用途以外にも応用が利きそうなので、引き続き触ってみたいと思います。