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はゲームサーバー用途以外にも応用が利きそうなので、引き続き触ってみたいと思います。