proto-cl-client-side-rendering に画像描画機能を追加した話

前回記事でクライアントサイドレンダリング (2D) のためのライブラリを実装してみました。

eshamster.hatenablog.com

この時点では丸や四角といったプリミティブな図形の描画しかできませんでした。画像(テクスチャ)の描画機能も必要だろうなと思いつつ面倒なので後回しにしていました。そして、ようやく実装したというのが今回の話ですが、思った通り面倒だったので後から見返すためのメモを残しておこうという趣旨のメモ記事です。

見返し用に作ったセルフプルリク↓

github.com



面倒なところ

おおむね下記が面倒なところです。

  • 単一の画像ファイルに複数の画像が埋め込まれている
  • 透過処理のために2つの画像(元画像とアルファマップ)が必要になる
  • 層によってデータの形が異なる
    • ライブラリ外部へのインタフェース層
    • プロトコル
    • クライアント層

用語について

本題の前に、textureとimageという用語を勝手に定義して使ってしまっているのでその説明です。

  • texture: 物理的な画像ファイル1つ1つに対応する情報
    • 正確には、アルファマップがある場合は元画像 + アルファマップの2ファイルに対して1つのtextureが対応します
  • image: 1つのtextureに対して複数のimageが対応する。1つの画像ファイルは複数の画像を含む場合があり、どこからどこまでを1つの画像として扱うかという情報をimageで持っている

何となくtextureの方が具象的な印象があり、一方でimageの方が抽象的な印象があったのでこの名付けにしていますが、そんなに深くは考えていません。

うまく説明できている気がしないので具体例で見ます。

f:id:eshamster:20170812024633p:plain

「A」と「B」という2つの画像を含んだ1つの画像ファイルで、これが1つのtextureに対応します。実際に描画する際には、「A」は「A」、「B」は「B」で個別に扱いたいとします。そのためには2つのimageをこのtextureに対応づけます。1つ目のimageはtextureの左半分の領域を指定し、2つ目のimageはtextureの右半分の領域を指定します。そして、「A」を描画したいときには1つ目のimageを、「B」を描画したいときには2つ目のimageを選択して描画する、といった形になります。

各層の実装

下記の層を順番に見ていきます。

  • ライブラリ外部へのインタフェース層
  • プロトコル
  • クライアント層

ライブラリ外部へのインタフェース層

利用例として、ライブラリ配下に置いているサンプル sample/sample-texture.lisp を見てみます。

まず、画像ファイル = textureをロードする部分です。 multiple_image.png とそのアルファマップである multiple_image_alpha.png(パスの起点は後述)に :multiple-image という名前をつけてロードしています。

  (load-texture :name :multiple-image
                :path "multiple_image.png"
                :alpha-path "multiple_image_alpha.png")

これは先程も載せた、「A」と「B」を1つの画像ファイルにまとめたものです。

f:id:eshamster:20170812024633p:plain

ここから2つのimageを抽出します。1つは左半分を取り出した :a で、もう1つは右半分を取り出した :b です。取り出し範囲は、画像の左下を (0, 0)、右上を (1, 1) とする、いわゆる UV 座標系で指定します。

  (load-image :image-name :a
              :texture-name :multiple-image
              ;; 補足: デフォルトは x = 0, y = 0, width = 1, height = 1
              :uv (make-image-uv :width 0.5))
  (load-image :image-name :b
              :texture-name :multiple-image
              :uv (make-image-uv :x 0.5 :width 0.5))

こうして作成した image は次のように使います。:image-name として :a を指定し、あとは座標や大きさなど描画に必要な情報を指定します。

    (draw-image :id (incf id)
                :image-name :a
                :x 500 :y 300
                :width 50 :height 50
                :rotate (* -1/10 *temp-counter*)
                :depth 0 :color #xffffff)

この辺りのインタフェースは良い感じにできたんじゃないかなーと思ってます。

いったん飛ばした、画像指定時に起点となるパスはミドルウェアmiddleware.lisp)に渡す引数から決まり、<resource-root>/<image-relative-path> になります。先程のサンプルでは <proto-cl-client-side-renderingのルートパス>/sample/resource/img フォルダにしています。

(defun make-client-side-rendering-middleware (&key
                                                resource-root
                                                (image-relarive-path "img/"))
  (ensure-js-files  (merge-pathnames "js/" resource-root))
  (set-image-path resource-root image-relarive-path)
  ...

プロトコル

クライアントに情報を送付するプロトコル層ですが、ここはインタフェース層と大きくは変わりません。

これは protocol.lisp で定義していて、おおむねインタフェース層の load-texture, load-image, draw-image に対応していることが見てとれると思います(frame, index-in-frame は前回記事と同じなので詳細略)。

(defun send-load-texture (frame index-in-frame
                          &key path alpha-path texture-id)
  (send-message :load-texture frame index-in-frame
                `(:path ,path :alpha-path ,alpha-path :texture-id ,texture-id)))

(defun send-load-image (frame index-in-frame
                        &key texture-id image-id uv-x uv-y uv-width uv-height)
  (send-message :load-image frame index-in-frame
                `(:texture-id ,texture-id :image-id ,image-id
                  :uv-x ,uv-x :uv-y ,uv-y :uv-width ,uv-width :uv-height ,uv-height)))

(defun send-draw-image (frame index-in-frame
                        &key id image-id x y depth color width height rotate)
  (send-draw-message :draw-image frame index-in-frame
                     `(:image-id ,image-id :width ,width :height ,height :rotate ,rotate)
                     :id id
                     :x x :y y :depth depth :color color))

これらのプロトコルを抽象化してインタフェースとして提供する層が texture.lisp です。ざっくり次のような役割を持っています。

  • インタフェースの提供
    • load-texture
    • load-image
    • draw-image
  • texture, image それぞれについて、idと名前の対応付け
    • インタフェースとしては名前を利用する
    • プロトコルにはidの方を渡している
  • 適切なタイミングでの上記プロトコルのクライアントへの送信
    • send-load-texture, send-load-image
      • 接続済みのクライアントについては load-texture, load-image が呼ばれたタイミング
      • 後からつないできた新規接続のクライアントについては接続したタイミング
    • send-draw-image
      • (通常の描画命令と同じく)draw-image が呼ばれたタイミング

クライアント層

クライアント層は受け取ったプロトコルに応じて実際に画像をロードし、Three.js に適切にデータを渡して画像の描画を行います。

同じような処理は cl-web-2d-game でも一度書いたことがある...のですが、すっかり忘れていて調べ直したので、この機会に一度メモを残しておきたいところです。というのが今回の記事を書こうと思った大きな要因だったりします。

この辺りの処理は client/texture.lisp に書いています。

まずはデータ構造を整理します。

ゴールとなるデータ構造は THREE.Mesh です。これを、THREE.Scene に追加することで描画ができます。THREE.Mesh のコンストラクタには次の2つを渡します。

  • THREE.Geometry: ジオメトリ = 形状に関する情報
    • vertices: 頂点情報。THREE.Vector3 の配列
      • 今回は四角形にテクスチャを貼り付けるので4頂点必要
    • faces: ポリゴン情報。THREE.Face3 の配列
      • THREE.Face3vertices に登録したどの3頂点で三角形を構成するかを表す
      • 今回は2つの三角形で1つの四角形を表すので2つ必要
    • face-vertex-uvs: テクスチャの貼り方についての情報。THREE.Vector2 の2重配列
      • faces で指定した各頂点にテクスチャのどの部分を割り当てるかを UV 座標で表す
  • THREE.MeshBasicMaterial: マテリアル = 見た目に関する情報
    • map: THREE.Texture ≒ 画像を渡す
    • alpha-map: map に同じく THREE.Texture で、こちらはアルファマップ = 透過情報を示す画像を渡す
      • これを利用する場合は transparent を true にする
    • color: ベースとなる色
      • 画像の色に合成される

次にこのデータ構造と各種プロトコルの関係をみます。

  • :load-texture
    • THREE.TextureLoader により、マテリアルに渡す mapalpha-map をロードする
    • ロード後にテクスチャ情報をテーブルに保存しておく
  • :load-image
    • どの texture にひも付いているかと、ジオメトリに渡すUV座標の情報を保存する
      • load という名前ではあるが実は何もロードしていない
  • :draw-image
    • :load-texture, :load-image それぞれで保存した情報を使って、 実際にジオメトリとマテリアルを生成して THREE.Mesh をつくる

実装については、基本的に上記のプロトコル・構造に基づいて淡々と実装していくだけですが、画像ロード周りはどうしても非同期処理がからむのでそこだけ少々面倒です。

まず、:load-texture の部分です。Promiseを利用して、画像(下記の bitmap-image)とそのアルファマップ(下記のalpha-bitmap-image)をロードを非同期に待ってから、texture の情報(texture-info 構造体;定義略)をテーブルに保存します。

(defun.ps load-texture (&key path alpha-path id)
  (let* ((loader (new (#j.THREE.TextureLoader#)))
         (image-promise
          (make-texture-load-promise loader path))
         (alpha-image-promise
          (make-texture-load-promise loader alpha-path)))
    (chain -promise
           (all (list image-promise alpha-image-promise))
           (then (lambda (images)
                   (push (make-texture-info
                          :id id
                          :bitmap-image (aref images 0)
                          :alpha-bitmap-image (aref images 1))
                         *texture-info-buffer*))))))

(defun.ps make-texture-load-promise (loader path)
  (new (-promise
        (lambda (resolve reject)
          (if path
              (loader.load path
                           (lambda (bitmap-image)
                             (console.log (+ path " has been loaded"))
                             (funcall resolve bitmap-image))
                           (lambda (err)
                             (console.log err)
                             (funcall reject err)))
              (funcall resolve nil))))))

次に、:load-image については特に非同期的な要素はなく、単に image の情報(image-info 構造体;定義略)をテーブルに保存するだけです。

(defun.ps+ register-image (&key id texture-id
                                uv-x uv-y uv-width uv-height)
  (setf (gethash id *image-info-table*)
        (make-image-info :id id
                         :texture-id texture-id
                         :uv-x uv-x
                         :uv-y uv-y
                         :uv-width uv-width
                         :uv-height uv-height)))

最後の :draw-image も非同期的な要素があって少々面倒です。指定された image(にひもづくtexture)がロード済みか(image-loaded-p)で処理を変える必要があります。ロード済みの場合は素直にテクスチャーつきの THREE.Mesh を生成するだけです。ロード済みでない場合、いったん幅と高さを合わせたジオメトリと、単色のマテリアルで THREE.Mesh を生成します。そして、ロードが完了した段階で正式なジオメトリとマテリアルに差し替えます。

(defun.ps make-image-mesh (&key image-id width height color)
  (flet ((make-geometry-and-material ()
           (let ((img-info (find-image-info-by-image-id image-id))
                 (tex-info (find-tex-info-by-image-id image-id)))
             (values
              (with-slots (uv-x uv-y uv-width uv-height) img-info
                (make-image-geometry :width width
                                     :height height
                                     :uv-x uv-x
                                     :uv-y uv-y
                                     :uv-width uv-width
                                     :uv-height uv-height))
              (make-image-material :tex-info tex-info
                                   :color color)))))
    ;; If the image has not been loaded, returns a temoral mesh with
    ;; same width, height, and monochromatic. Then, rewrites by the image
    ;; after loading it.
    (unless (image-loaded-p image-id)
      (let ((result-mesh (new (#j.THREE.Mesh#  ; 仮のMesh
                               (make-image-geometry :width width
                                                    :height height)
                               (new (#j.THREE.MeshBasicMaterial#
                                     (create :color #x888888)))))))
        ;; 補足: register-func-with-pred は cl-ps-ecs
        ;; (https://github.com/eshamster/cl-ps-ecs) で定義している関数で、
        ;; フレーム開始時に第2引数で渡した条件をチェックして、条件を満たしていたら
        ;; 第1引数に渡した処理を実行する
        (register-func-with-pred
         (lambda ()
           ;; 正式なジオメトリとマテリアルに差し替える
           (multiple-value-bind (geometry material)
               (make-geometry-and-material)
             (setf result-mesh.geometry geometry
                   result-mesh.material material)))
         (lambda () (image-loaded-p image-id)))
        (return-from make-image-mesh
          result-mesh)))
    ;; The case where the image has been loaded.
    (multiple-value-bind (geometry material)
        (make-geometry-and-material)
      (new (#j.THREE.Mesh# geometry material)))))

なお、ここで利用している、ジオメトリを生成する make-image-geometry とマテリアルを生成する make-image-material は、前述のデータ構造に従って地道にデータを組み上げるだけです。

(defun.ps make-image-geometry (&key width height
                                    (uv-x 0) (uv-y 0) (uv-width 1) (uv-height 1))
  (let ((geometry (new (#j.THREE.Geometry#))))
    (setf geometry.vertices
          (list (new (#j.THREE.Vector3# 0 0 0))
                (new (#j.THREE.Vector3# width 0 0))
                (new (#j.THREE.Vector3# width height 0))
                (new (#j.THREE.Vector3# 0 height 0))))
    (setf geometry.faces
          (list (new (#j.THREE.Face3# 0 1 2))
                (new (#j.THREE.Face3# 2 3 0))))
    (let ((uv-x+ (+ uv-x uv-width))
          (uv-y+ (+ uv-y uv-height)))
      (setf (aref geometry.face-vertex-uvs 0)
            (list (list (new (#j.THREE.Vector2# uv-x  uv-y ))
                        (new (#j.THREE.Vector2# uv-x+ uv-y ))
                        (new (#j.THREE.Vector2# uv-x+ uv-y+)))
                  (list (new (#j.THREE.Vector2# uv-x+ uv-y+))
                        (new (#j.THREE.Vector2# uv-x  uv-y+))
                        (new (#j.THREE.Vector2# uv-x  uv-y ))))))
    (geometry.compute-face-normals)
    (geometry.compute-vertex-normals)
    (setf geometry.uvs-need-update t)
    geometry))

(defun.ps make-image-material (&key tex-info color)
  (let ((alpha-bitmap (texture-info-alpha-bitmap-image tex-info)))
    (new (#j.THREE.MeshBasicMaterial#
          (create map (texture-info-bitmap-image tex-info)
                  alpha-map alpha-bitmap
                  transparent (if alpha-bitmap true false)
                  color color)))))

おわりに

これは忘れる!