Edited at

Go のゲームライブラリ Ebiten における API 設計思想

More than 1 year has passed since last update.


tl;dr (要約)

Ebiten はスーファミっぽいゲームを作るために作られた Go のゲームライブラリです。実際のスーファミのゲームから必要であろうという要素を抜き出して要件を定義し、それを満たすように最小限の API を定義しました。ゲームのコード行数は多くなりがちですが、明確さを優先しました。特に描画周りについては、描画元と描画先を Image という構造体で一緒くたに扱い、さらに内部状態を単なるピクセルの集合として扱えるようにすることで、分かりやすく使いやすい API になりました。


Ebiten とは何か

Ebiten は「レトロな (スーファミっぽい) 2D ゲームを作るための Go のライブラリ」です。

この文章では、 Ebiten を使いたいユーザー向けにはもちろんのこと、ゲームライブラリ開発者向けにも、 API 設計の思想的な部分を紹介します。


hello_world.go

package main

import (
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
)

func update(screen *ebiten.Image) error {
if err := ebitenutil.DebugPrint(screen, "Hello world!"); err != nil {
return err
}
return nil
}

func main() {
ebiten.Run(update, 320, 240, 2, "Hello world!")
}



対応プラットフォーム


  • Windows

  • macOS

  • Linux

  • Android

  • iOS

  • Web ブラウザ (デスクトップの Chrome、 Firefox、 Safari) (GopherJS によって Go を JavaScript に変換して実行)


機能

コンポーネント
機能

2D グラフィックス
幾何・色行列変換、コンポジション、オフスクリーンレンダリング

入力
マウス、キーボード、ゲームパッド、タッチ

オーディオ
Ogg/Vorbis、 WAV、 PCM


Ebiten のポリシー

Ebiten で「何ができるのか」はサイト過去の記事を見てもらえば分かるのですが、今回はそこでは語られていない、設計思想である「なぜ (API などが) そのようになっているのか」について説明したいと思います。

Ebiten のポリシーは「スーファミっぽいゲームという要件を満たすための必要最小限の API を定義する」です。 API をミニマムにするというのは、他のゲームライブラリあまりみられない点であると考えています。 Ebiten は「どんなゲームでも実現できるライブラリ」ではありません。あくまで「スーファミっぽいゲームを作れる」ことを主眼においており、 API 設計時における取捨選択はこの目的に沿っているかどうかで判断されます。

API が必要最小限であるということは、実現したいゲームの機能を実装するためにはある程度冗長に書かなければならないということでもあります。よくゲームライブラリの宣伝文句として「○○の機能が数行で実現できる」というのを見ますが、 Ebiten はサンプルを見ていただければ分かる通り、数行で実現できることはほぼありません。

具体例を見てみます。単純に画像を回転させるのにもメインロジック部分で十行程度は要します。回転するためには画像を幾何行列で指定して描画するのですが、中心を定めて回転するためには、移動と回転の行列の掛け算を複数回行わなければならないからです。

func update(screen *ebiten.Image) error {

count++
w, h := gophersImage.Size()
op := &ebiten.DrawImageOptions{}
// 画像の中心で画像を回転させたい。まず画像の中心を原点 (左上) に持ってくる。
op.GeoM.Translate(-float64(w)/2, -float64(h)/2)
// 回転する。
op.GeoM.Rotate(float64(count%360) * 2 * math.Pi / 360)
// 画面中心に再移動させる。
op.GeoM.Translate(screenWidth/2, screenHeight/2)
// 描画する。
if err := screen.DrawImage(gophersImage, op); err != nil {
return err
}
return nil
}

「行列なんぞ使わないで、画像の中心と角度を指定して回転させる API を作る」というのも考えられます。それはそれで一つの解ですが、この場合例えば拡大と組み合わせるとどうなるかとか、任意の行列もオプションで指定したくなった場合どっちを優先するかなど、考慮しなければならない別のことが増えてしまいます。 Ebiten ではあらゆる幾何変形についてアフィン行列のみの指定にすることによって、矛盾がなく挙動が明確な API になっています。

Ebiten にはコンパクトさにかけるという意味でのエレガントさはないのですが、逆に言うとゲームのコードが非常に明確になるということでもあります。 Ebiten ではエレガントに書けるかどうかは重要視していないのです。また最小限の API といっても、 API は十分汎用であり、この最小限の API をうまく組み合わせれば、やりたいことが概ね実現できると考えています。


関数の数

Ebiten パッケージにおける関数および型は現在のバージョン (1.5.0 alpha) 時点では、 deprecated なもの及び別パッケージになっているオーディオなどを除くと、以下で全部です。関数は 41 個しかありません。他のライブラリは Ebiten とは違う機能を持つので、フェアな比較は困難ですが、同類のライブラリの中では少ない方なのではないかと思います。

func CurrentFPS() float64

func CursorPosition() (x, y int)
func GamepadAxis(id int, axis int) float64
func GamepadAxisNum(id int) int
func GamepadButtonNum(id int) int
func IsGamepadButtonPressed(id int, button GamepadButton) bool
func IsKeyPressed(key Key) bool
func IsMouseButtonPressed(mouseButton MouseButton) bool
func IsRunningSlowly() bool
func Run(f func(*Image) error, width, height int, scale float64, title string) error
func RunWithoutMainLoop(f func(*Image) error, width, height int, scale float64, title string) <-chan error
func ScreenScale() float64
func SetCursorVisibility(visible bool)
func SetScreenScale(scale float64)
func SetScreenSize(width, height int)
func Touches() []Touch
type ColorM
func Monochrome() ColorM
func (c *ColorM) ChangeHSV(hueTheta float64, saturationScale float64, valueScale float64)
func (c *ColorM) Concat(other ColorM)
func (c *ColorM) Element(i, j int) float64
func (c *ColorM) RotateHue(theta float64)
func (c *ColorM) Scale(r, g, b, a float64)
func (c *ColorM) SetElement(i, j int, value float64)
func (c *ColorM) Translate(r, g, b, a float64)
type CompositeMode
type DrawImageOptions
type Filter
type GamepadButton
type GeoM
func (g *GeoM) Concat(other GeoM)
func (g *GeoM) Element(i, j int) float64
func (g *GeoM) Rotate(theta float64)
func (g *GeoM) Scale(x, y float64)
func (g *GeoM) SetElement(i, j int, element float64)
func (g *GeoM) Translate(tx, ty float64)
type Image
func NewImage(width, height int, filter Filter) (*Image, error)
func NewImageFromImage(source image.Image, filter Filter) (*Image, error)
func (i *Image) At(x, y int) color.Color
func (i *Image) Bounds() image.Rectangle
func (i *Image) Clear() error
func (i *Image) ColorModel() color.Model
func (i *Image) Dispose() error
func (i *Image) DrawImage(image *Image, options *DrawImageOptions) error
func (i *Image) Fill(clr color.Color) error
func (i *Image) ReplacePixels(p []uint8) error
func (i *Image) Size() (width, height int)
type ImageParts
type Key
type MouseButton
type Touch


グラフィックス


要件定義

「スーファミっぽい」ゲームの要素を以下のようにざっくりとまとめました。なお筆者はスーパーファミコンのプログラミングについては全く詳しくなく、実際に存在するゲームから「恐らく必要だろう」という機能を列挙したものであり、漏れやダブリがあるかもしれません:


  • スプライト

  • タイリング (例: RPG におけるマップ)

  • ラスタスクローリング (例: マリオカート、 FF6 の飛空艇)

  • 拡大縮小回転 (例: FF4 の飛空艇)

  • 色調変換 (例: RPG における夜の表現。実際の実装はパレットアニメーションである)

  • 半透明 (例: FF でモンスターが死ぬところ)

  • パレットアニメーション (例: RPG における敵の色違い)

  • モザイク (例: FF のテレポ)

Wikipedia にあるスーパーファミコンの仕様を見るに、概ねこれで合っているようです。

また上記にはありませんが、以下の機能が「RPG ツクール 2000」にあり、便利であろうと考えたため追加しました:


  • 加算合成


要件に対応する機能

上記要件に対して、描画先および描画元両方を表す Image 構造体を作り、 Image から Image への描画ですべて実現する、という方法を取りました。


  • スプライト



    • (*Image).DrawImage (サンプル)。座標の指定は GeoM (幾何行列・ 2 次アフィン行列) で指定する。



  • タイリング



    • (*Image).DrawImageImageParts を指定する。



  • ラスタスクローリング



    • (*Image).DrawImageImageParts を指定する。一つの Image を短冊状に細切れにして描画することでタイリングと同じく実現可能 (サンプル)。また Image は描画元の画素データだが、それ自体描画対象にもなりえるため、オフスクリーンレンダリングの要領で、テンポラリな描画バッファを Image で用意し、そこでの描画済み画面をラスタスクロールすることも可能。



  • 拡大縮小回転



    • (*Image).DrawImageGeoM (幾何行列) を指定する (サンプル)。なお行列なのでせん断変形もできる。



  • 色調変換



    • (*Image).DrawImageColorM (色行列・ RGBA の 4 次アフィン行列) を指定する (サンプル)。



  • 半透明



    • (*Image).DrawImageColorM (色行列) を指定する (サンプル)。



  • パレットアニメーション


    • 厳密な意味でのパレットアニメーションは用意しなかった。色行列で概ねやりたい描画を実現と考えている。または、パレットアニメ済みの Image を作り、それを描画元として DrawImage してもよい。最終手段として (*Image).ReplacePixels でピクセル単位の操作が可能だが、重い処理である。



  • モザイク



    • (*Image).DrawImageGeoM (幾何行列) を指定し、 Nearest フィルターで縮小したあとで拡大する (サンプル)。ラスタスクロールで説明したのと同様、描画結果も Image なので、オフスクリーンレンダリングの要領で描画結果にもモザイクがかけられる。



  • 加算合成



    • (*Image).DrawImageComposition を指定する。



その他解像度について、 QVGA (320×240) 程度の低解像度はもちろん解像度の上限は特に作られていません。ただし以下の点が特徴として挙げられます:


  • 動作確認は主に QVGA で行われている。高解像度でもパフォーマンスに問題はないと考えているが、高解像度だと特にブラウザにおいてはパフォーマンスに問題がでるかもしれないし、実際問題が起きた場合の対応優先度は高くない1

  • Ebiten の機能として、ドット感を保ったまま画面全体を拡大する機能がある (Run 関数の引数に拡大倍率を指定できる)2


Image 構造体

Ebiten において描画元と描画先は同じ構造体です。もともと描画元と描画先は違う構造体にすることも検討していたのですが、動作効率は多少犠牲にしてでも3 API を単純化し、使いやすくしたほうがいいと考えました。結果的にあらゆる描画状態は Image という一つの構造体にまとめることにしました。画像ファイルから生成されるテクスチャ、オフスクリーンのバッファ、最終描画結果もすべて Image で表されます。描画の用途によって使い分けする必要がなくなったのです。

さらに、 Image の内部状態は単なるピクセルの集合に留めることにより4、関数呼び出し結果を予想しやすいものにしました。

以上の工夫から、描画周りについては小さく使いやすい API になったと思います。


オーディオ

オーディオについてはステレオの PCM が鳴らせればよく、任意の io.Reader (厳密には io.ReadCloser かつ io.Seeker) を再生出来るようにしました。任意のループはもちろん、ホワイトノイズや無限サインウェーブなどなんでも鳴らせます。

オーディオは別途 audio パッケージで定義しています。別パッケージになっているのは、オーディオ部分は後発であるという歴史的経緯もありますが、描画や入力部分とは一応独立して使えるという意図もあります。


入力

「スーファミ風」という要件を考えるとゲームパッドとマウスがあれば十分ですが、 PC およびスマートフォン対応としてキーボードとタッチが使えます。


おわりに

今回は Ebiten の使い方紹介というよりは、 Ebiten のゲームライブラリとしての設計思想について書きました。ゲームライブラリ設計の参考というよりは、こういう考え方もあるんだなあ程度に思っていただければ幸いです。





  1. 逆に言うと「スーファミっぽい」と標榜しているからこそ、高解像度でパフォーマンスがあまり出なくても許されるわけです :-) 



  2. 画面拡大には整数倍以外も浮動小数点を指定できますが、その場合はその値以上の最小の整数倍に Nearest フィルタで拡大した後、 Linear フィルタで縮小するという手順をとります。スマートフォンでフルスクリーン表示したい場合、拡大率はたいてい整数倍になりませんが、そのようなケースに便利です。特に最近のスマートフォンはディスプレイ解像度が高く、このやり方で十分自然なドット感がでます。 



  3. 実際問題になりうる点としていくつかありますが、例えば、コンテキストロストからの復帰のために、特定の条件下では GPU からピクセルデータをとってくる必要がありますが、「描画先の状態が後から変わりえる」という仕様だとその条件を満たすパターンが多くなり、処理が遅くなる可能性があることなどがあります。変態的な使い方をしなければいずれも問題ありません。 



  4. 使用者は Image を単なるピクセルの集合として透過的に扱ってよいという意味であり、実際の内部実装はもっと複雑です。たとえば描画命令をためておくためのバッファがあったり、コンテキストロストに備えてピクセル情報を保存しておいたりしています。