カスタムペイントでお絵かき

2013年6月29日


プログラミングで一度はやってみたくなるのがお絵かき。線を引いたり色を塗ったりを繰り返して、好き勝手なアニメーションを作ってみます。

BitmapとCanvasとPaint

Bitmap(画像データ)をCanvas(描画できる対象)として、描く、みたいな仕組み。

Bitmapは、文字通りピクセルの集合からなるデータ。アルファありの32bitピクセルが標準。Javaみたいに、アルファ無しにしてスピードアップする手段が使えないようだ。

Canvasは、描く対象というような意味。基本的にBitmapに対応づけて作る。UIの描画ルーチンのように、何に書き出しているかを伏せたまま、Canvasが与えられるメソッドもある。

色や線の太さ等の描画のパラメータは、Canvasではなく、Paintというオブジェクトが持つ。Canvasは、できたときからBitmapとの対応を内部で持っているが、Paintとの関係はない。描画処理毎に、Canvasのメソッドとして、引数にPaintを渡す形になる。メソッドによってはPaintの部分をNULLとできるものもあり、そうするとデフォルトの描き方になるようだ。

Paintが、図形の塗り込みの有無まで指定するなど、Java2Dとはかなり違う仕様になっているので注意。

Viewクラスの派生クラスに描く

Viewクラスを直接派生させて、描画処理を行うメソッドをオーバーライドすれば、独自の描画を行うビューができる。

Viewクラスを派生させたカスタムなビュークラスは、onDrawメソッドをオーバーライドすると、表示を自前で行うように変更できる。superのonDraw()を実行する必要はない。 onDraw()は、Canvasを渡してくれるので、それを使えば画面を書ける。

onDraw()の実行のタイミングは、システムが勝手に決める。startActivity()や画面回転などで、アクティビティーができたときなどに限られ、おおむね描いた画像はキャッシュされているようだ。描画はUIスレッドで行われる。

この方法では、キャンバスの実態が何かは分からない。意外とちらつかないけれども、前もって用意したビットマップに書くのも1つの方法だろう。

キャンバスが得られると、幅や高さなどをピクセルで簡単に得られる。getWidth()やgetHeight()を使う。

線や文字などの描画は、キャンバスのメソッドで行う。ただし、色や線の太さなどの付加情報は、Paintオブジェクトで指定する。Paintオブジェクトは、最初に1つは作っておきたい。描画コマンドごとにCanvasとPaintを組み合わせて使うところが、swingとは少し違う。

Viewクラスを直接派生させた場合のonDraw()は、Viewの表示をやり直す必要性をOSのUI管理が認めた場合にしか実行されない。

手軽にカスタムペインティングのコードを実行できるが、静止画向けだ。表示ルーチンのオーバーライドが基本なので、背後のスレッドでゲームのロジックを更新し続けるような、ハードなゲーム作りには向いていない。

アニメーション表示させたいなどの理由で、今すぐ書き換えたいときは、invalidate()を実行する。このinvalidate()を、AsyncTaskを使い、ときどきThread.sleep()を使いながらのループの中で実行すると、アニメーションっぽく画面を更新することもできる。ただし、onDraw()はUIスレッドでの描画であり、必ずしも効率的ではない。ちなみに、別スレッドでonDraw()を実行してはだめ。

独自派生クラスをXMLのUI定義で使う方法

独自の派生クラスのインスタンスも、XMLファイルによるUI定義からインフレート(インスタンス化)できる。

<view class="com.example.helloworld.AnimeView" >

のように書けば、viewクラスの派生クラスであれば、指定したクラス名のインスタンスを用意できる。

XMLでインフレートするクラスは、コンストラクタで

public AnimeView(Context context, AttributeSet attrs);

みたいに、ContextとAttibuteSetを受けるバージョンを用意する必要がある。インフレートしてくる側がこれを呼んでくるから。

super(context,sttrs)をとりあえず呼べば、ViewクラスとしてのXMLでの修飾は受けられる。後は、独自の初期化をすればよい。

XMLに他のUI部品を配置すれば、カスタムなビューと通常のボタン等を同じ画面に共存させられる。

この方法は、Viewを直接派生させた場合も、SurfaceViewを派生した場合も、両方とも使える。

XMLを使わずにUIの構成をインスタンス化する方法

アクティビティーのonCreate()で、直接UI要素(View、レイアウト等)をインスタンス化して、UIの構成としてセットしてもよい。

レイアウトやカスタムビューのインスタンス等を作り、setContentView()に渡す。 なお、レイアウトを指定せず、直接ビューをsetContentView()に渡してもよい。このとき、MATCH_PARENT等の設定をしなくても、画面いっぱいになる。

アクションバーを非表示にするには

アプリケーションのマニフェストXMLで、親アクティビティーや、アクティビティーのラベルを設定すると、とくに指定しなくても、アクションバーが表示され、左上をタップすると、親アクティビティーに戻る。

マニフェスト内で、アクティビティーにandroid:theme="@android:style/Theme.NoTitleBar.Fullscreen"の指定をすると、タイトルバーなしのフルスクリーンにできる。ただし、Back, Home, アプリ一覧のボタンは消せない。

Canvas(キャンバス)への描画コマンドあれこれ

drawRect(x1,y1,x2,y2,paint)で塗りつぶした長方形を描く。(x1,y1)から(x2-1,y2-1)までが範囲となる。(x2,y2)そのものは範囲外となるところがポイントだ。

drawLine(x1,y1,x2,y2,paint)で直線を引く。見たところ、デフォルトのpaintでは(x2,y2)を含まない実装になっているようだ。

drawText(s,x,y,paint)で文字列を書く。sはテキスト。x,yは左下の座標を示す。

drawPoint(x,y,paint)で点を描く。(x,y)だけが有効。

Paint(ペイント)への設定あれこれ

setColorで色を付けられる。

色は、Color.rgb(r,g,b)やColor.argb(a,r,g,b)でとりあえず取得可能。

ペイントにsetARGB()で、Colorオブジェクトを使わず直接数値を入れてもよい。

アルファ値は、255が完全に不透明、0が完全に透明。

setTextSize()でフォントサイズを決められる。数値は、日本語を入れた場合、おおむねピクセル数に合わせられているようだ。

measureText()でテキストの幅を、おおむねピクセルで得られる。

setStrokeWidth()で、線幅を決められる。指定すると、長方形的な線になる。

幅を0にすると、1ピクセル描くモードになる。標準も0になっている。

長方形とかを塗りつぶすか、輪郭だけ描くかは、setStyleで決める。Paint.Style.FILLがデフォルトで、塗る。輪郭だけならPaint.Style.STROKEとする。

FILLでもSTROKEでも、Line, Rectのx2(右)、y2(下)の指定は描く範囲に含まれない点では同じ。

SurfaceViewでマルチスレッド描画

SurfaceViewは、マルチスレッドで次々と画面を書き換えていく処理に適したUI部品。画面を書き換えるべきタイミングを内部で調整できる。毎秒60コマとかで画像を表示するには便利。重いゲームで、スムーズな操作感を実現できる。

画面の描画はUIスレッドの責任。重いゲームをUIスレッドだけで作ると、ゲーム意外の操作に対してもレスポンスが悪くなり、扱いにくい。極端にUIスレッドを止めてしまうと、Application Not Respoindingの画面が出ることもある。

マルチスレッドにして、裏でじっくり時間をかけて画像を用意し、画像を書き換えるときだけ、一瞬だけメインスレッド側から画像を書き換える権利を借りる使い方をすると、裏のスレッドをフル活用した重いゲームのスレッドを動かしつつ、UIスレッドを軽く保てるので、軽快に動作する。

SurfaceViewは、そういう使い方を目的にしたViewの派生クラスの仲間。

Surfaceホルダーを使い、キャンバスを取得し、描画し、キャンバスを返却して画面更新、というのを、別スレッドからできる。

SurfaceViewのキャンバスは、ハードウェアアクセラレーションできない。Androidは、前もってイメージやバッファに描画するときは、ハードウェアアクセラレーションが使えないらしい。JavaのSwingだと、相手がバッファでも描画は速かったので、少し考え方が遅れているのかもしれない。

キャンバスのロックが戻ってくるのは、次のコマをポストするバッファがあいたときのようだ。あくのは60分の1秒ごと。これは、Nexus 7の画面切替のタイミングかな?空く前からロックしようとすると、空くタイミングまでブロックされる。空いた状態でロックしようとすると、すぐにロックできる。

ループの途中でとくにsleep()させなくても、キャンバスのロックの場面で、バッファが空くのを待つことになるので、CPUを使い尽くさないようにできる。

キャンバスの解放とポストでは、それほど待たされない。といっても2msくらいかかることが多いけど。

キャンバスを取得したら、別途作った画面イメージをコピーして、すぐポストするようにすれば、キャンバスのロックを最小限にできる。このやり方が、ゲーム作りの基本と思われる。

この方法を使えば、自然に60fpsで画面更新ができるようになる。いわゆるvsyncに従う形か。vsyncそのものを明示的に扱うAPIを見たことがない。調べればあるのかもしれないけれども。

ちなみに、キャンバスをロックして得るときに、すぐに取得できるのは、次のフレームがセット済みでないときだけなので、今のフレームの表示終了タイミングまでにpostできれば、まあよい。つまり、すぐに取得可能となってからも、約60分の1秒猶予がある。

なので、Canvasの取得に待たされているときが大多数であれば、実際には、手前のフレームの表示終了までにpostが間に合わないことは、ほとんど起きていないようだ。

フレームレートは、450個イメージを描いても、59.95くらい出ていて、ほぼ60fpsを達成。

もともとが、59.94なのか60.00なのか分からない。HDMIがついてるやつだと59.94ベースなのか?とか考えたくなる。

ビットマップをはりこみたい

ビットマップのデータ自体は、Bitmapクラスのファクトリメソッドで、ごく簡単に作成できる。

PNGファイルなどからBitmapのデータを生成するには、まずres/drawableのディレクトリに、使いたいPNGファイルを入れておく。手元のプロジェクトにはres/drawable-hdpiのディレクトリになっているが、それしかないならそこへ入れればよい。多くの端末で最適な解像度で表示したいなら、ファイル名は同じで解像度の違うやつをたくさん並べればよい。

次に、BitmapFactoryクラスのdecodeResource(getResources(), R.drawable.beam1)メソッドを使い、リソースと、リソース名から、Bitmapのインスタンスを得る。

リソース名は、PNGファイルの名前から".png"を取り除いたもので、ビルド環境が自動的にその名前をRクラスの中にソースとして作り出すようになっている。

ビルド環境の支援がないと、簡単にはイメージを取り出せないということでもある。大量にチップを並べるときは、リソース名を大量に並べるのはたいへんだから、最初から多数のチップを含む1つの画像ファイルみたいにしておいたほうがよいのかもしれない。

デコードしたイメージは、大きさが、本来の画像ファイルから、透明ピクセル分抜いた大きさになっていることもあるので、よく確認したほうがよい。

ちなみに、ViewのCanvasに直接書くよりも、用意したBitmapに書いたほうが、動作が少し速い。

純粋なBitmapからCanvasを得ると、ハードウェアアクセラレーションは無効扱いとなる。

ViewのonDrawが用意するCanvasは、ハードウェアアクセラレーションが有効になっている。

裏のスレッドでビットマップに描画して、いざ更新できるときに、Viewに描画する方法だと、ビットマップでの描画を高速化できない。今後のOSの改善を期待する。

HandlerでUIスレッドでの処理を予約

Handlerは、他のスレッドからUIスレッドに対してポストすることで、同期を取りながら動く仕組み。

Handlerのインスタンスは、どのスレッドから作られたかを覚えている。HandlerのインスタンスはUIスレッドで作る。インスタンスの参照はUIスレッドからだけでなく、別スレッドからも見られる場所に置いておく。

別スレッドからhandler.post(Runnableの参照);をやると、UIスレッドで、そのRunnableを、後で実行してほしい、という要求になる。

UIスレッドでは、イベントキューにそのRunnableを入れて、順番が回ってきたら実行する。だから、他スレッドからUIスレッドに対して、特定の処理の実行を指示できる。

別スレッドで画面書き換えのタイミングを調整し、UIスレッドで画面を書き換えたいときに使えるが、SurfaceViewを使ったほうが便利。

マルチタッチ入力でゲームを操作

アンドロイドは、入力がマウスではなくタッチパネル。ソフト的なインターフェースもマウス用とは少し異なる。

ゲームとして考えると、十字方向と、決定、キャンセルのボタンがほしい。

十字方向と、決定、キャンセルは、ともに同時に押せるようにしておきたい。

ドラッグして領域に入ってきたときは、押しているとみなす。

押してからドラッグして外へ出た場合は、離れたとみなす。

上と下、あるいは左と右を同時に押した場合も、それらボタンが同時に押されたとする。

基本的に、どのキーも同時押し可能とする。マルチタッチ対応を前提にする。

setOnTouchListener()で、押した、離した、ドラッグした等をイベントとして検出可能。

DOWN, POINTER_DOWNで新たにタッチ。UP, POINTER_UP, CANCELで指を離す、MOVEで押したまま移動、というのがイベントの基本。

同時に複数タッチしているときは、複数のポインタが同時に1つのイベントオブジェクトの中に入る。タッチの開始・終了のときは、ポインタ1つに対してイベントが出る。対象となるポインタは、離した時であれば、ボタンを押すポインタには含めないようにする。

MOVEの場合は、1つのイベントに複数の時点の情報が入ることもある。イベント受信時の瞬間だけで制御したい場合は、複数の時点の部分は捨てて、最新の時点だけで処理してもよい。軌跡をよりくっきり見せたいときは、複数の時点を全部使うと、指の軌跡をきれいな曲線にできるかも。

Nexus 7では、同時に10点まではタッチを認識できることを確認。指10本を、きちんと10本と認識している。なかなかこなれている。

押したとき、離したときの遅延はそれほどない。

動かしたときの遅延は、けっこうある。軌跡の遅れが、目ではっきりと分かる。

カーソルの座標値は、画面の実ピクセルで、対象となるビューの左上が原点(0,0)になる。

今のところは、左手に十字方向ボタン、右手に○、×のボタンを設ける案が有力。

キャラクターをスワイプしたり、キャラクタを原点として動きたい方向を押さえたりすると、画面が見えなくなってしまう。画面の中にメニューキーとかを用意すると、その分背景が見えなくなってしまう。遊んでいる所は、手で隠さないで見て欲しいから。キャラクターに直接さわりたいという考え方には、多少の考慮がいるか?

永続的にゲームの状態を保持したい

アクティビティーからの参照だけに頼ると、例えば画面を回転させるとActivityのインスタンスが作り直しになるので、ゲームの進行がリセットされてしまう。

ゲームの進行状態を、別の場所で長く取っておきたい。詳細はまだ調べていない。

ゲーム用の継続性のあるクラスを用意したい(画面を回しても消えないやつ)。

画面を回転させたりすると、Activityが再生成されるので、アクティビティのレベルでしか保存していないと、状態が消えてしまう。

ゲームの進行状況は保存しておきたいので、対策を考える。やるとしたら、こんな感じ。

後者のほうがおすすめだと思う。ただし、メモリが逼迫すると、そのクラスも勝手に解放されて、データが消滅する可能性がある。これは、仕方がない。

そのクラスを参照するアクティビティーが作り直されたときに、再び同じインスタンスの参照を取り戻す方法は、知っておきたい。

アクティビティーが作り直されるときは、実行するスレッドも変わってしまっているかもしれない。以前のスレッドとの衝突を避けながら、先ほどの続きを表示したい。

ゲームの経過をセーブしたい

アプリケーションのデータとして、ゲームの進行状況を保存するようにしたい。shared propertiesでもよいだろうし、きちんとファイルにしてもいいかもしれない。保存できる件数なども工夫したいところだ。

ゲームに必要な処理能力はどれくらい?

生の解像度で1280x800を、16x16のチップでうめようとすると、とても60fpsでは動かないと思われる。

調べた限りでは、バッファのイメージサイズが大きいと、全ピクセル書き換えのために苦戦するもよう。全画面に近いイメージだと、16x16の単純な四角を塗るだけでも、256個くらいが限度か。

イメージサイズを320x240まで縮めると、16x16の四角であれば、2000個くらい書いても平気になる。平気というのは、SurfaceViewのキャンバスをロックする 場面で、出画待ちでウェイトがある状態になること。ここですぐキャンバスが もらえるようだと、前回のコマが画面に出た後なので、あと1コマ猶予があるものの、コマ落ちのリスクが出ている。

60fpsで2000個の正方形なので、12万正方形/秒くらいか。

塗る対象を、BufferedImageのようなBitmapにしたら、いくつ塗れるかを再チェック。450個くらいが限度のようだ。

ちなみに、CPU使用率は、描画スレッドが100%使い切っていても、クアッドコアだと25%までしか上がらない。小さい数字だからといって、そのスレッドに余裕があるわけではないので、Canvasロック待ちで評価するのが正しいと思う。

Java2Dと比べると、能力的に多少劣っている。多層構造のマップにすると厳しいかもしれない。

スケーリングで320x240を伸ばして表示

CanvasのdrawBitmap()には、画像を拡大して表示する機能がある。

画面いっぱいに拡大表示すると、相当こたえるようで、16x16の四角は700個くらいが60fpsの限度となる。

いくつもスレッドを使い、パイプラインのようにするのも一つの方法だが、遅延が大きくなるのでゲーム性が下がってしまうし、ソフトの見通しが悪くなる。ほどほどのバランスで作ることが大切。

ローカルのビットマップでも、GPUの機能が使えればいいのに、今のAndroidではだめ。

画面サイズに合わせ、中央におさまるように320x240を表示するには、表示先の領域を選ぶときに、ちょっとこだわる必要がある。

画面をスケールさせてdrawBitmapするときは、アンチエイリアスはきかない。単純にカクカクなままで拡大縮小される。

文字にアンチエイリアスを入れるかどうかは、ゲームデザイン上の問題。見た目にしっくりくるほうがよい。

サンプルゲームを作る

画面デザイン。十字キーと○×と、画面中央に絵を描く。

タッチパネルは反応が鈍いので、アクションよりも、ロールプレイングが本命かな。

簡単に完成させられるゲームとして、まずは超簡易インベーダーゲームを作る。

スプライトパターンとして、背景を透明色としたPNGファイルを用意する。

PNGファイルの配置は、res/drawable-hdpiとしておく。

16×16ピクセルを標準とする。

作ったら、そこそこ動いた。まずはここからスタート。あとは、もっと面白くすればいいだけ。


杉原俊雄のホームページAndroidアプリ開発メモ(もくじ)

(c) 2013 Toshio Sugihara. All rights reserved.