クライアントサイドレンダリング (2D版) を Common Lisp で実装してみる

2019年と令和元年、二重に本年最初の記事です。あけましておめでとうございます。

クラウドゲームをつくる技術 ―マルチプレイゲーム開発の新戦力 (中嶋謙互) (以下、「クラウドゲーム技術」)で提唱されている「クライアントサイドレンダリング」が面白そうだったので Common Lisp でプロトタイプ (2D版) を書いてみています。

作成しているものは下記です。今回の記事用に blog-20190506 というタグを打っています。記事中のソースへのリンクも同タグのものを貼っています。

github.com

プロトタイプとしても不足が目立つので記事にするには時期尚早な感じですが、GW中に一記事仕上げてみた次第です。

なお、こんなショボショボなプロトタイプでなくちゃんとしたものを見たい方は、同書と kengonakajima/moyai を参照しましょう(実装はC++)。



導入:クライアントサイドレンダリング概要

クラウドゲーム技術」で提唱されているクライアントサイドレンダリングについて簡単に見てみます。関連する定義は次の2つです。

クラウドゲーム:

ゲームロジック(ゲームのあらゆる判定)のすべてがサーバー側で実行され、エンドユーザー側はその処理結果をリアルタイムに受信する汎用のViewer(ゲームビューワー)を通じてプレイする設計になっているゲーム

~「クラウドゲーム技術」: 「1.1 クラウドゲームとクラウドの基礎知識」>「クラウドゲームの定義」より~

クライアントサイドレンダリング

クライアントサイドレンダリングとは、サーバー側でビデオゲームの画面へのレンダリングを行わず、抽象度の高い描画コマンドをクライアントに送って、クライアント側のGPUを使って描画をする方法です。

~「クラウドゲーム技術」: 「1. 5 クラウドマルチプレイ最大の課題『インフラ費用』」>「『クライアントサイドレンダリング』という発想」より~

ネットワークを通じてゲームを配信する従来の方法と比較すると分かり易いです。下記2つが従来の方法です。

  1. 専用クライアント方式:ゲーム専用のサーバとクライアントを実装する方法(Final Fantasy XIV など)
    • ロジック処理は分担し、描画はクライアントのみが担う
  2. ストリーミング方式:サーバ側でロジック処理から描画まで行い、クライアントへストリーミングする方法(PlayStation Now など)
    • (入力はクライアントで受け付けてサーバへ送信する)

1 の方法はクライアントを専用に作成するだけあって、ロジック処理や通信などサーバ側の負荷を低く抑えられる利点があり、大量のユーザを捌くことができます。一方で、ネットワーク処理や同期処理を中心にプログラミングのコストが非常に高くなる欠点があります。

2 の方法では利点・欠点が逆転します。つまり、単一サーバでの動作となるためオフラインゲームと同程度のプログラミングコストで実装ができる一方で、GPU処理の負荷やストリーミング帯域の大きさからユーザ数の限界は小さくなります。

以上を踏まえて改めてクラウドゲームとクライアントサイドレンダリングの定義を見ると、これらの中間を行く手法であることが分かります。つまり、1 の方法よりもいくらか高い程度のサーバ負荷・通信量で、オフラインゲームと同程度のプログラミングコストという方法 2 の利点を享受することができるという訳です*1

ところでクラウドゲームの定義の「すべてがサーバー側で実行され」という文言を見て、何かを思い出す人もいるのではないでしょうか。そう、ポール・グレアムの「普通のやつらの上を行け ---Beating the Averages---」です。

もしあなたが、あなたのサーバー上でだけ走るソフトウェアを書くのなら、 あなたは自分の好むどんな言語でも使えるということだ。 (中略) もしどんな言語でも使えるとなったら、いったい何を使う? 私達はLispを選んだ。

つまりそういうことです。

作ったもの

概要

  • クライアント
  • 通信
    • クライアント自体は静的なHTML, JavaScriptとして配信します
      • なお、HTML はcl-markupを利用して書いています
    • 描画コマンドと入力のやり取りには WebSocket を利用します
    • プロトコルJSON形式(を文字列化したもの)で定義します
      • 柔軟ですが非常に帯域効率が悪いです。moyaiでは遥かにまともなプロトコルを定義していますが、まあプロトタイプだし...
  • サーバ
    • 上記のクライアント・通信から分かるように、静的コンテンツの配信機能とWebSocket通信機能を持ちます

デモっぽいもの

デモ…というより、動作確認用のサンプルの動かし方とスクリーンショットです。動作確認は Clozure CL 1.11.5 で行っています。

まず、依存ライブラリの1つとライブラリ自身がQuicklisp未登録なのでcloneしてきます。

$ cd <quicklisp が検知できるフォルダ>
$ git clone https://github.com/eshamster/ps-experiment.git
$ git clone https://github.com/eshamster/proto-cl-client-side-rendering.git

次にREPL上でライブラリのロードとサーバの起動を行います。

CL-USER> (ql:register-local-projects) ; ※quickload がライブラリを認識しない場合
CL-USER> (ql:quickload :sample-proto-cl-client-side-rendering)
CL-USER> (sample-proto-cl-client-side-rendering:start :port 5000)

ブラウザから http://localhost:5000/ にアクセスします。

f:id:eshamster:20190506021417g:plain

GIF 中の各種オブジェクトについて:

  • 縦軸中央付近で常に動いている5つの円や矩形は基本的な図形描画の確認用
    • 他の図形も含め、描画指示はサーバから出していて、ブラウザ側はビューアとしてのみ働いています
  • 白い円は入力の確認用
    • GIF 中ではマウスドラッグに追随させています
    • 他にキーボードの矢印入力でも動きます
    • 位置情報はサーバ側で持っているため、ブラウザを更新しても位置が保たれます
  • 右上の青や赤の円はクライアントごとに別の情報を送る確認用
    • クライアントにはサーバ側で連番IDを振っています。そして、偶数番には赤い円を、奇数番には青い円を送付しています。 以上から、ブラウザを更新するとIDが振り直され、表示される情報が変わります
      • GIF中では一度ブラウザを更新していますが、その前後で青丸が赤丸になります
    • …という分かりにくデモです
  • 左上の枠内の文字列・数値はデバッグ情報
    • 1フレーム内にサーバから送付されてきたコマンドの種類と数、さらにその総数を表示しています

なお、動作がカクカクしているのは約2FPSという超低レートにしているためです。さすがにこれが限界値という訳ではなく、現状の動作が不安定な段階では低レートでじっくり見れた方が良いとの判断からです。

もう少し詳細

もう少し詳細な部分として、プロトコル・サーバ処理・クライアント処理について見てみます。

途中でなんのためにこんな詳細を書いているのか分からなくなってきたのですが、ちょっと詳しめのメモぐらいのスタンスで適当に書いていきます。

プロトコル (JSON)

サーバからもしくはクライアントから送られる一つ一つのコマンドは次の形をしています(JSON)。

{
  "kind": ...,  // コマンドの種類を表す (int)
  "frame": ..., // 何フレーム (*) 目かを表す数値 (int) [server to client のみ]
  "no": ...,    // フレーム内での連番 (int)  [server to client のみ]
  "data": {
    ... // コマンドごとの固有の情報 (hash)
  }
}
// (*) ゲームのいわゆる1フレームのこと

kind はこのコマンドが何であるかを示しています。現状定義しているコマンドは protocol.lisp の下記になります。

;; ※ ~.ps+: Common Lisp用とJavaScript (Parenscript) 用の2つの定義を生成する
(defvar.ps+ *code-to-name-table* nil)
(defvar.ps+ *name-to-code-table* nil)

(defun.ps+ initialize-table ()
  (setf *code-to-name-table* (make-hash-table)
        *name-to-code-table* (make-hash-table))
  (dolist (pair '(;  server to client
                  (0 :frame-start)         ; フレームの開始
                  (1 :frame-end)           ; フレームの終了
                  (10 :delete-draw-object) ; 描画オブジェクトの削除
                  (11 :draw-rect)          ; 矩形描画
                  (12 :draw-circle)        ; 円描画
                  (21 :log-console)        ; コンソールログ出力
                  ;; client to server
                  (-1 :key-down)           ; キーボード押す
                  (-2 :key-up)             ; キーボード離す
                  (-11 :mouse-down)        ; マウス押す
                  (-12 :mouse-up)          ; マウス離す
                  (-13 :mouse-move)))      ; マウス動かす
    (let ((code (car pair))
          (name (cadr pair)))
      (setf (gethash code *code-to-name-table*) name
            (gethash name *name-to-code-table*) code))))

若干分かりにくいのは :frame-start:frame-end かと思いますが、クライアントはこの2つのコマンドの間に来たコマンドを1フレーム内のコマンドとして認識します*3

何か順序保証などに使えるかなと思って frameno といったフレームに関する情報をサーバから送っていますが、今のところ使っていません。

個別のデータである data 部分については、サーバから送る例として :draw-rect コマンドを、クライアントから送る例として :mouse-down コマンドを見てみます。

まず、:draw-rect コマンドの data 部分は次のようになります。

  data: {
    "id": ...,    // 描画オブジェクトのID (int)
    "x": ...,     // x 方向の位置 (float)
    "y": ...,     // y 方向の位置 (float)
    "depth": ..., // 描画順序 (float)
    "color": ..., // 色 (int [RGB])
    "fill-p": ... // 図形内部を塗り潰すか (bool)
    "rotate": ... // 矩形の回転 (float [rad])
    "width": ...  // 矩形の幅   (float)
    "height": ... // 矩形の高さ (float)
  }

おおむね見ての通りかと思いますが、id だけ補足します。本ライブラリでは「クラウドゲーム技術」に従い、更新のあった(または新規の)描画オブジェクトの情報のみをクライアントに送信する差分更新を行います。そのため、クライアント側はどの描画オブジェクトを更新すべきかを識別する必要があり、そのときに利用するのが id になります*4

次に、:mouse-down コマンドです。

  data: {
    "button": ..., // 押されたボタンの種類 (string)
    "x": ...,      // x 方向の位置 (int)
    "y": ...,      // y 方向の位置 (int)
  }

これと言って説明するものもなく見ての通りです。

サーバ処理

サーバ側の処理として、通信処理・描画処理・入力処理周りをみます。これらはライブラリのルートフォルダ上に置いています。

通信処理

各クライアントとのWebSocketの情報は下記の構造体で管理します。

(defstruct client-info
  target-server
  (id (incf *latest-client-id*)))

target-server は、websocket-drivermake-server で作成するWebSocketサーバです。次のようにしてクライアント側へメッセージを送信することができます。

(websocket-driver:send (client-info-target-server hoge) "some message")

実際にそうした処理をしているのが、同 ws-server.lisp 内の send-from-server です。

(defvar *target-client-id-list* :all
  "If ':all', a message is sent to all clients.
Otherwise, it is sent to the listed clients.")

(defun send-from-server (message)
  (dolist (client-info (copy-list *client-info-list*))
    (let ((server (client-info-target-server client-info))
          (id (client-info-id client-info)))
      (case (ready-state server)
        (:open (when (or (eq *target-client-id-list* :all)
                         (find id *target-client-id-list*))
                 (send server message)))
        (:closed (format t "~&Connection closed: ~D" id)
                 (setf *client-info-list* (remove client-info *client-info-list*))
                 (maphash (lambda (key callback)
                            (declare (ignore key))
                            (funcall callback id))
                          *callback-on-disconnecting-table*))
        ;; otherwise do nothing
        ))))

サーバ側でクライアントにメッセージを送りたい場合はこの関数を呼び出します。この関数自体はプロトコルを意識せず単に文字列を送付するだけです。スペシャル変数 *target-client-id-list* が比較的重要で、特定のクライアントのみへのメッセージ送信を実現するためのものです。具体的には次のように使います。

;; ID2番と4番のクライントのみに送付する
(let ((*target-client-id-list* '(2 4)))
  (send-from-server "hoge"))

さて、一方でクライアントからの通信受け付け処理は make-server 後にコールバック関数としてひも付けます。

(defparameter *ws-app*
  (lambda (env)
    (let* ((server (make-server env))
           (client-info (make-client-info :target-server server))
           (client-id (client-info-id client-info)))
      (push client-info *client-info-list*)
      ;; (略)
      (on :message server       ; ← コールバック関数のひも付け
          (lambda (json-string)
            ;; (略)
            ))
      (lambda (responder)
        (declare (ignore responder))
        (format t "~&Connection started: ~D" client-id)
        (start-connection server)))))

ここで定義している *ws-app* がサーバ側でWebSocket通信の開始を受け付ける lack アプリケーションです。ライブラリ外部に対しては、これを組み込んだlackミドルウェアを生成する下記の関数を提供します(middleware.lisp)。

(defun make-client-side-rendering-middleware (&key resource-root)
  ;; クライアントで利用する外部JavaScriptのダウンロード
  (ensure-js-files  (merge-pathnames "js/" resource-root))
  (lambda (app)
    (lambda (env)
      ;; クライアントの実体を生成
      (output-client-js (merge-pathnames "js/client.js" resource-root))
      (let ((uri (getf env :request-uri)))
        (if (string= uri "/ws")
            (funcall *ws-app* env)
            (funcall (make-static-middleware ; "/js/..." に来たらJavaScriptファイルを返す
                      app :resource-root resource-root)
                     env))))))

WebSocket開始の通信を *ws-app* に流す他、クライアントの実体である JavaScript ファイルの生成やその配信も担っています(少々盛り込み過ぎ感)。

使う側は次のような感じです(sample/server.lisp)。

(defvar *ningle-app* (make-instance 'ningle:<app>))

(defun start (&key (port 5000))
  (stop) ; 動いてたら止める
  (start-sample-game-loop)
  (setf *server*
        (clack:clackup
         (lack:builder
          (make-client-side-rendering-middleware
           :resource-root (merge-pathnames
                           "resource/"
                           (asdf:component-pathname
                            (asdf:find-system :sample-proto-cl-client-side-rendering))))
          *ningle-app*)
         :port port)))

HTML 部分の定義が同サンプルファイルの下の方にあるのですが、本来はガワだけ用意してライブラリ側で面倒見るべきだろうなと思っています。

描画処理

プロトコル定義に従ってクライアントに各種コマンドを送信する関数群は protocol.lisp で定義しています。

(defun send-message (kind-name frame index-in-frame data)
  (send-from-server
   (to-json (down-case-keyword `(:kind ,(name-to-code kind-name)
                                 :frame ,frame
                                 :no ,index-in-frame
                                 :data ,data)))))

(defun send-draw-message (kind-name frame index-in-frame data
                          &key id x y depth color)
  (send-message kind-name frame index-in-frame
                `(:id ,id :x ,x :y ,y :depth ,depth :color ,color ,@data)))

(defun send-draw-rect (frame index-in-frame
                       &key id x y depth color fill-p width height rotate)
  (send-draw-message :draw-rect frame index-in-frame
                     `(:fill-p ,(bool-to-number fill-p)
                       :width ,width :height ,height :rotate ,rotate)
                     :id id
                     :x x :y y :depth depth :color color))

send-message は全コマンド共通の関数です。具体的なコマンドの例として、矩形描画用の send-draw-rect 関数(とそれが利用している描画用の共通関数 send-draw-message)を載せています。受け取ったパラメータを所定のJSON形式に変換しているだけです。

:frame(フレーム番号)や :no(フレーム内連番) をライブラリ利用者側に指定させるのも微妙なので、外部に提供する関数は game-loop.lisp で上記のラッパーとして作成しています*5

(defstruct draw-info sender client-id-list param-table)

(defvar *draw-info-table* (make-hash-table)
  "Key: id, Value: draw-info")
(defvar *prev-draw-info-table* (make-hash-table))

(defun draw-rect (&key id x y depth color fill-p width height rotate)
  (setf (gethash id *draw-info-table*)
        (make-draw-info :sender #'send-draw-rect
                        :param-table (init-table-by-params
                                      id x y depth color fill-p width height rotate)
                        :client-id-list *target-client-id-list*)))

先程 protocol.lisp で定義していた send-draw-rect ですが、直接は呼び出さずいったん *draw-info-table* というテーブルにコマンドの情報を格納しておきます。フレームの終わりに下記の process-all-draw-messages でまとめて送信します。

(defun process-all-draw-messages ()
  (maphash (lambda (id draw-info)
             (let ((*target-client-id-list* (calc-target-client-id-list id)))
               (call-sender-by-param-table
                (draw-info-sender draw-info)
                (draw-info-param-table draw-info))))
           *draw-info-table*)
  ;; (略: 前回フレームにしか存在しない描画オブジェクトの削除コマンドを生成する)
  (switch-draw-info-table))

基本的には *draw-info-table* に格納した情報に従って描画コマンドを送るだけですが、送り先クライアントの指定(*target-client-id-list*)は前フレームの情報を加味して少々いじります。3行目の calc-target-client-id-list がそれで、定義は次の通りです。

(defun calc-target-client-id-list (object-id)
  (let ((info (gethash object-id *draw-info-table*)) ; 今フレームの情報
        (prev-info (gethash object-id *prev-draw-info-table*))) ; 前フレームの情報
    (let ((list-in-info (draw-info-client-id-list info)))
      (cond ;; 1. 新規オブジェクトであれば指定されたクライアントへコマンドを送る
            ((null prev-info) list-in-info)
            ;; 2. 差分がなければ送らない
            ;;    ただし、新たに接続したクライアントが対象に含まれている場合は、
            ;;    それらに対してのみコマンドを送る
            ((same-draw-info-p info prev-info)
             (if *new-client-list*
                 (calc-common-target list-in-info *new-client-list*)
                 nil))
            ;; 3. 差分があれば指定されたクライアントへコマンドへ送る
            (t list-in-info)))))

差分更新を実現するために、コメントに入れたような形でコマンドの送り先を決定しています。

入力処理

クライアントから受け取った入力情報を処理しているのが input.lisp です。さほど書くことがないのでポイントだけ。

  • WebSocketを通して送られてきた入力情報は process-input-message 関数で処理する
  • フレーム途中で情報が変わるのを避けたかったため、同関数はバッファに書き込むだけ
  • フレーム開始時に update-input を呼ぶことで、バッファの情報を参照してライブラリ外に見せる情報を更新する
    • 呼び出しているのは game-loop.lisp

ライブラリを使う側は次の例のように入力状態を取得します。

;; Xキーが押された直後(1フレーム)であるかを知りたい
(key-down-now-p :x)
;; エンターキーが押された状態であるかを知りたい
(key-down-p :enter)
;; マウスの位置を知りたい
(multiple-value-bind (x y)
  (get-mouse-pos))

クライアント処理

クライアント側の処理として、サーバと同じく通信処理・描画処理・入力処理周りをみます。これらは clientフォルダ 内で定義しています。また、Lisp として書いていますが、全て JavaScript に変換した上でブラウザへ送付します。

通信処理

WebSocketの定義周りと受信時の処理周りを見ていきます。

まず、WebSocket通信周りは client/socket.lisp で定義しています*6

短いので全体抜き出します。

;; ソケット作成
(defvar.ps *ws-socket*
    (new (#j.WebSocket# (+ "ws://" window.location.host "/ws"))))

;; メッセージ受信時のコールバック登録
(defun.ps register-socket-on-message (callback)
  (setf *ws-socket*.onmessage
        (lambda (e)
          (funcall callback e.data))))

;; サーバへのメッセージ送信
(defun.ps send-json-to-server (json)
  (*ws-socket*.send ((@ #j.JSON# stringify) json)))

Parenscript では比較的素直な JavaScript コードに変換されるので、おおむね見ての通りだと思います。

次に、受信時の処理、すなわち上記に登録するコールバックを見ます。client/message.lispで定義している process-message 関数が該当のものです。

(defun.ps receiving-to-json (message)
  (#j.JSON.parse# message))

(defun.ps+ process-message (message)
  (let ((parsed-message (receiving-to-json message)))
    (push-message-to-buffer parsed-message)
    (when (target-kind-p :frame-end parsed-message)
      ;; (略: フレーム内に送られてきたコマンドを集計するデバッグ処理)
      (queue-draw-commands-in-buffer)
      (setf *frame-json-buffer* (list)))))

プロトコルの項で説明したように、フレームの開始と終わりはそれぞれ :frame-start コマンドと :frame-end コマンドで示されます。そのため、:frame-end が来るまではバッファにコマンドを溜め込みます(push-message-to-buffer*7:frame-end が来た段階でバッファに溜まった描画コマンドをまとめてキューに詰め込み(queue-draw-commands-in-buffer)、バッファをクリアします。

描画用には別途 Three.js のループが回っているため、キューに詰め込まれた描画コマンドはそちらで拾われます。そちらの処理は次の「描画処理」の中で見ていきます。

描画処理

描画処理には大きく2つのパートがあります。

  1. 四角や丸のモデルを生成するプリミティブな関数群
  2. サーバから送られた描画コマンドを解釈して1を呼び出す処理

1の関数群は client/graphics.lisp で定義しています。ジオメトリ(頂点など形状情報)とマテリアル(色など描画情報)を詰め込んだ THREE.Mesh というクラスのインスタンスを返します。これを THREE.scene クラスに add したり remove したりすることで、オブジェクトを描いたり消したりできます。個別の処理については、地道に頂点等の情報を作成しているだけなので詳細略です。

2のエントリポイントとなる部分、すなわち「通信処理」でキューに詰めた情報を取り出しているのは client/core.lisp で定義している update-draw 関数です。

(defun.ps+ update-draw (scene)
  (let ((draw-commands (dequeue-draw-commands)))
    (when draw-commands
      (dolist (command draw-commands)
        (interpret-draw-command scene command)))))

処理の実体である interpret-draw-command 関数は client/message.lisp で定義しています。これは次の add-or-update-mesh 関数を呼び出しているだけなので、こちらを見ていきます。

(defun.ps+ add-or-update-mesh (scene command)
  (let* ((kind (code-to-name (gethash :kind command)))
         (data (gethash :data command))
         (id (gethash :id data))
         (prev-info (gethash id *draw-info-table*)))
    (cond ((eq kind :delete-draw-object) ; delete
           (remhash id *draw-info-table*)
           (remove-mesh-from-scene scene (draw-info-mesh prev-info)))
          ((null prev-info) ; add
           (let* ((mesh (make-mesh-by-command command)))
             (setf (gethash id *draw-info-table*)
                   (make-draw-info :kind kind
                                   :data data
                                   :mesh mesh))
             (add-mesh-to-scene scene mesh)))
          ((should-recreate-p prev-info kind data) ; recreate
           (remhash id *draw-info-table*)
           (remove-mesh-from-scene scene (draw-info-mesh prev-info))
           (add-or-update-mesh scene command))
          (t ; simple update
           (update-common-mesh-params
            (draw-info-mesh prev-info) data)
           (setf (draw-info-data prev-info) data)))))

2つほどポイントがありますが、1つは差分更新を行うための現在の描画情報の管理です。これは下記のように定義した *draw-info-table* というハッシュテーブル上に載せています。

(defstruct.ps+ draw-info
  kind ; :draw-rect/:draw-circle
  data ; 描画コマンド
  mesh ; THREE.Mesh
)

(defvar.ps+ *draw-info-table* (make-hash-table)
  "Key: id, Value: draw-info")

なお、サーバ側でもこうしたフレーム間比較のための情報を持っていましたが、そちらでは今フレームと前フレームの情報を管理するため2枚のテーブルでした。一方こちらは、(送られてきたコマンドそのものが今フレームの情報であるため)1枚のテーブルを直接書き換える形を取っています。

もう1つのポイントとなるのは Three.js のアーキテクチャに依存した(ちょっとした)最適化です。ここで、add-or-update-meshcond 内部を見ると "delete", "add", "recreate", "simple update" の4つの処理があります。前者2つは見たままですが、後者2つの区別が該当の最適化になります。Three.js では一度 add したメッシュについては、位置や角度*8の更新だけであれば、単純に値を更新すれば反映してくれます。これが "simple update" です。それ以外の、例えば頂点情報の更新などはもう一手間あるため、楽をして再作成をしています。これが "recreate" です。

入力処理

入力処理については、サーバと同じでそれほど見所はありません。基本的には各種入力イベントに反応してサーバへ入力情報を送信するだけです。

一応、例としてマウス関連のコマンドの送信部分を見てみます。入力系はマウス関連含め、client/input.lisp で定義しています。

(defun.ps send-mouse-message (kind e)
  (multiple-value-bind (x y)
      (calc-adjusted-input-point e.client-x e.client-y))
  (send-json-to-server (ps:create :kind (name-to-code kind)
                                  :data (ps:create
                                         :button (mouse-button-to-string e.button)
                                         :x x
                                         :y y))))

入力位置補正のための calc-adjusted-input-point の中身がちょっと汚かったりするのですが目をつむります。


足りないもの

色々足りないのですが、思い付くもので比較的大きめのものを順不同に。

フレームレートの高速化・安定化

デモの所で書いたように、現状 2FPS というひどいフレームレートで動作しています。このため、60FPS(か30FPS)で安定動作させる必要があります。

クライアント側は既に60FPSで回っているので、サーバ側の sleep 処理をいじるだけといえばそれだけです。が、昔 C# + Windows Forms なゲームライブラリを作ってフレームレートの安定化に苦労した記憶があるので中々腰が上がりにくく…。

また、現状クライアント側は描画コマンドキューにいくらでも溜め込むようになっているので、これを避ける必要があります。定常的な動作については、多少溜まってもキューがなくなるまでコマンドを処理をしてから描画、とすれば(時々カクつくぐらいで)問題なさそうです。ただ、デバッガで描画ループを止めた場合などはすごい勢いで溜まっていくので、一定以上溜まった場合はキューの中身を破棄して全体再送するようにサーバに要求する必要があると思います。もしくは、破棄した上で接続を打ち切り、リロードしてくださいでも実用上問題ないかもしれません。

なんにせよ必須ではあるのでどこかでやります。

サーバ側からクライアントの初期化パラメータを指定する

現状スクリーンのサイズを横800・縦600(論理的なサイズ。実サイズはブラウザの画面サイズに応じてスケールしています)で決め打ちにしていますが、サーバ側からこれを指定できるようにする必要があります。

洗い出してませんが他にもサーバから指定すべきパラメータはありそうです。

ドローコールの削減

現状描画オブジェクトごとに単に一つのMeshを作成して add しているため、描画オブジェクトごとにドローコールがなされてしまいます。今後描画オブジェクトが増えると致命的にパフォーマンスに影響してくるため、可能な限り少ないドローコールで済むように改善が必要です。

クライアントごとのカメラの管理・移動

現状、左下座標が(0,0)、右上座標が(800, 600)になる位置でカメラを固定していますが、「クラウドゲーム技術」に従い、位置や縮尺をクライアントごとに移動できるようにする必要があります。それができてからの話ですが、カメラ外にある描画オブジェクトのコマンドを送信しないというカリング処理も必要になってきます。

中継 (レプリケーション) サーバ

クラウドゲーム技術」ではサーバとクライアントの間に、描画コマンドを受け取って(複数の)クライアントに情報を配信するだけのレプリケーションサーバが記述されています。サーバ側のカリング処理などが軽くなり、許容クライアント数を大幅に増やすことができます。

とはいえ、そこまで大規模化する展望はまだないので優先度は低いです。

ログ用クライアントとリプレイ

これも「クラウドゲーム技術」に記載の内容ですが、クライアントに送信する描画コマンドをライブラリ側で全て把握していることから、描画コマンドを保管しておくことでリプレイ機能を容易に実現できるという嬉しい性質があります。

単一のクライアントのリプレイだけであれば、クライアントと全く同じ情報を保管しておけば十分そうです。ただ、任意のクライアントのリプレイを実現するためには、どのクライアントに向けたコマンドであるかも同時に保管しておく必要がありそうです。

ゲーム用ライブラリのガワを被せる

作成したライブラリはゲームライブラリというより描画・入力ライブラリなので、実際にゲームを書くにはもう一層ライブラリを被せる必要があります。

これには、以前作った(現在も作りつつある)cl-web-2d-game を移植すればベースは簡単にできるのではと思ってます。同ライブラリはブラウザ側で全てのロジックを処理する前提で書いたものですが、描画・入力周りを除いた結構な部分は Common Lisp, JavaScript 共通で動作するコードとして書いているためです。

認証機能

現状つなぎ直した場合にクライアントの同一性を確認する手段がないので、認証機能、というよりは他の認証機能と連携できるような機能が必要になりそうです。この辺全然考えられていません。

続きの記事

eshamster.hatenablog.com


*1:冗長になるので本文からは省きましたが、クライアント側の(主にGPUの)負荷は専用クライアント方式と同程度に高くなります

*2:lack のミドルウェアについては、以前簡単に紹介したことがあるので見てみると参考になる…かもしれません → Common Lispでホットローディングを試しに作る (2) 実装について。ちなみに、今回作ったライブラリはその記事で実装した cl-hot-loads を下敷にしていたりします

*3:フレームの区切りについてはサーバ側に責任があるので、クライアントから送るコマンド(= 入力系)については、フレームの区切りを意識せずに単純に送るだけです

*4:なお、差分更新をしない場合は、毎フレーム全ての描画オブジェクトを削除して全部描き直すだけなので、idは不要です

*5:game-loop.lisp に置くのは適切でない気はしていますが…

*6:ここを含め、全体的にクライアント側とサーバ側でファイル命名の一貫性が弱いのでどこかで直したいなと思いつつ

*7:半端な描画を防ぐため、本来は:frame-startの方も考慮すべきですが現状さぼってます

*8:他にもありますが、ここで見ているのはそれだけです