Edited at
EbitenDay 11

Ebiten の描画実装 (単純な API を実現するための水面下の努力)

今回は Ebiten の描画処理実装のお話です。 Ebiten にはごくごく単純な描画 API しか持っていませんが、これを高速に実行し、妥当な描画結果を得るために、様々な涙ぐましい努力が行われているのです。なお、この記事は Ebiten の実装の話ですが、 Go の話は殆どありません。

Ebiten の執筆時最新版の実装で解説します。


tl;dr

Ebiten には基本的に「画像 (ピクセルの集合) を画像の上に描画する」という API しかありません。これが GPU の描画命令に至るまでに、内部では様々な処理が行われています。具体的にはミップマップ、テクスチャアトラス、コンテキストロスト時の復帰、バッチ処理です。


背景

Ebiten の Image には DrawImage という関数がありますが、これは画像から画像へ、幾何行列や色行列などを指定して描画する関数です。これを素直に GPU の描画命令 1 つに落としてしまうと、パフォーマンスが非常によくありません。実際過去の Ebiten はほぼそのように実装していたため、問題になっていました (Issue 509)。これを解決するために、なるべく描画命令を減らす処理が必要となります。

そのほか、モバイル環境で頻繁に起きるコンテキストロストも問題になります。ユーザーにコンテキストロストを意識させない単純な API を保ったまま、コンテキストロストに対処する必要があるわけです。



ebiten.Image

(実装)

Ebiten ユーザーが直接触れる API。 DrawImageImage から Image に描画することができます。単色に塗りつぶす FillClear も実は透明な画像を使って描画しているだけなので、 DrawImage と実質同じです。最近導入された DrawTriangles も頂点処理が異なるだけで、 DrawImage と同じところに行き着きます。


ebiten.mipmap

(実装)

ebiten.mipmap はミップマップ画像の集合です。 ebiten.Image と一対一対応します。

Mipmap というのはリニアフィルターで描画のための、予め用意された縮小画像です。画像の縮小処理は描画元画像の複数のピクセルから描画先画像のピクセル画素を決定する処理です。ニアレストフィルターの場合はその複数のピクセルのうちの代表値を採用するだけですが、リニアフィルターの場合は複数のピクセルを混ぜ合わせた平均値を採用します。縮小率が大きい場合、その複数のピクセルというのをうまく取ってこれず、きれいな描画結果にならないことがあります。そのため「予め縮小された描画」というのを用意し、描画元画像として縮小率の近い画像を使うことで、きれいな描画結果を得ることができます。

Ebiten では 1/2、 1/4、 1/8… という画像を必要に応じて作ります。オリジナル画像を含めて、画像の集合は *shareable.Image というオブジェクトになっています。


shareable.Image / shareable.backend

(実装)

shareable.Imageshareable.backend の一部の領域を指すオブジェクトです。 shareable.basckend はテクスチャアトラスを表すオブジェクトです。

テクスチャアトラスは複数種類の画像を一つの画像にまとめたものです。これにより GPU の描画命令を削減することができます。 GPU の描画命令は描画に使用するテクスチャ (GPU 上での画像) が変わるたびに命令を変更する必要があります。そのためなるべく同じテクスチャを使用し続けたほうが、命令数が減るのです。

Ebiten はこのテクスチャアトラスを自動的に作成します。アルゴリズムは至って単純で、上下または左右に領域を分割する二分木になっています。事前に入力が予測できないいわゆる「オンラインアルゴリズム」です。また、テクスチャアトラス上に何かを描画することは今のところできないので、描画命令があった時点でその領域をコピーして新しいテクスチャを作る処理も行っています。

Ebiten では EBITEN_INTERNAL_IMAGES_KEY 環境変数を指定することで、内部の sharable.Image オブジェクトをダンプすることができます。たとえば「いのべーしょん」でダンプした結果の一部は次のようになります (見やすさのために一部加工しています)。画像ファイルではもともと別のものが結合して一つの画像にまとまっています。文字も一文字一文字が *ebiten.Image なのですが、 shareable.Image 上ではテクスチャの領域の一部となっています。


restorable.Image

(実装)

restorable.Image はコンテキストロスト時に復帰するロジックを含んだ画像です。

他プロセスがメモリを確保するなどの目的で GPU 上のメモリを追い出すことがあり、結果として GPU メモリ上のデータがなくなります。この状態をコンテキストロストといいます。デスクトップの OpenGL では起き得ないのですが、モバイルの OpenGL ES では頻繁に起きます。単純にアプリケーションを切り替えるだけで発生します。復帰処理がないとテクスチャなどのデータが消滅してしまい、ゲームが継続できません。そのため Ebiten では restorable.Image に復帰のための情報をもたせています。具体的には DrawImage などの履歴です。この履歴から、 Image 同士の依存関係グラフが作られます。復帰する場合はグラフの末端から順番に復帰していきます。

Ebiten はあらゆる Image からあらゆる Image への描画が可能になっているので、依存関係がループしたりすることがあります。そうすると復帰処理が複雑になってしまうので、一定条件を満たした場合に restorable.Image を stale (不安定) 状態にし、依存関係グラフから除外します。この stale 状態は毎フレーム終了時に、 GPU から直接データを読むことで解決します(1)。


graphicscommand.Image

(実装)

graphicscommand.Image は描画コマンドをキューに貯めるオブジェクトです。これの DrawImage を読んでも即座に描画命令が実行されるわけではありません。連続した描画命令を融合できる場合はできる限り融合します。いわゆる「バッチ処理」で、描画処理をまとめることで GPU に対する描画命令数を減らし、パフォーマンスを向上させます。

Ebiten では、 ebiten.ImageDrawImage が次の条件を満たしたときに高速に処理されます (godoc)。これは graphicscommand パッケージ内で命令融合する条件なのです。


  • すべての描画先が同じ (A.DrawImage(B, op)A)。

  • すべての描画元が同じ (A.DrawImage(B, op)B)。


    • これは強い要請ではありません。 shareable.Image によってテクスチャアトラス化している可能性が高いからです。



  • すべての色行列の値がおなじか、または scale の値しか無い。

  • すべての CompositeMode の値が同じ。

  • すべての Filter の値が同じ。





  1. コンテキストロストはフレーム描画開始から完了までの間 (Android でいうと GLSurfaceView.Renderer.onDrawFrame が呼ばれている間) は発生しないはずです。なのでこの時点で GPU からデータを読み込むのは失敗しないはずです。