proto-cl-client-side-rendering に画像描画機能を追加した話
前回記事でクライアントサイドレンダリング (2D) のためのライブラリを実装してみました。
この時点では丸や四角といったプリミティブな図形の描画しかできませんでした。画像(テクスチャ)の描画機能も必要だろうなと思いつつ面倒なので後回しにしていました。そして、ようやく実装したというのが今回の話ですが、思った通り面倒だったので後から見返すためのメモを残しておこうという趣旨のメモ記事です。
見返し用に作ったセルフプルリク↓
面倒なところ
おおむね下記が面倒なところです。
- 単一の画像ファイルに複数の画像が埋め込まれている
- 透過処理のために2つの画像(元画像とアルファマップ)が必要になる
- 層によってデータの形が異なる
- ライブラリ外部へのインタフェース層
- プロトコル層
- クライアント層
用語について
本題の前に、textureとimageという用語を勝手に定義して使ってしまっているのでその説明です。
- texture: 物理的な画像ファイル1つ1つに対応する情報
- 正確には、アルファマップがある場合は元画像 + アルファマップの2ファイルに対して1つのtextureが対応します
- image: 1つのtextureに対して複数のimageが対応する。1つの画像ファイルは複数の画像を含む場合があり、どこからどこまでを1つの画像として扱うかという情報をimageで持っている
何となくtextureの方が具象的な印象があり、一方でimageの方が抽象的な印象があったのでこの名付けにしていますが、そんなに深くは考えていません。
うまく説明できている気がしないので具体例で見ます。
「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つの画像ファイルにまとめたものです。
ここから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.Face3
はvertices
に登録したどの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
により、マテリアルに渡すmap
とalpha-map
をロードする- ロード後にテクスチャ情報をテーブルに保存しておく
:load-image
- どの texture にひも付いているかと、ジオメトリに渡すUV座標の情報を保存する
load
という名前ではあるが実は何もロードしていない
- どの texture にひも付いているかと、ジオメトリに渡すUV座標の情報を保存する
: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)))))
おわりに
これは忘れる!