ツクールMVで学ぶ非同期処理【Bitmapのロード】

はいどーもこんばんは、無職オブ無職です。
「Unity 非同期完全に理解した勉強会」に触発されたので、本日はツクールMVにおける非同期処理について調べてみたいと思います。
本文は断定的に書いている箇所が多いですが、タイトルに「学ぶ」とある通り、勉強中なので間違ってる可能性もアリです。
そこのところだけご注意ください。

なおツクールMVのバージョンは1.6.1以降を想定しています。

非同期処理がおこなわれている箇所

ツクールMVはBitmapをロードするとき非同期処理をおこなっています。
他にも非同期処理をおこなっている箇所はあると思いますが、ここではBitmapを取り上げます。。

例えばImageManager.loadEnemyを実行してみます。
するとImageManager.loadBitmapが呼ばれ、Bitmapが戻り値として返ってきます。
ですが、このときBitmapのロードが完了しているとは限りません。
ロードが完了していない場合、例えば戻り値のBitmapのwidthやheightを取得しても0が返ってきます。
また、addLoadListenerで登録したイベントハンドラはロード完了後に実行されますので、何かしらイベントハンドラを登録していた場合、ロード前にBitmapをいじると何か不都合があるかもしれません。

つまりImageManager.loadEnemyを呼んで画像をロードしたあと、画像の幅と高さが欲しいならロードの完了を待つ必要があるのです。

ロードされていない場合の具体例

具体例を見ていきましょう。
例えばメニュー画面を以下のように改造したとします。

想定している動作は以下のようなものになるでしょう。

1.メニュー画面を開く
2.例えば「ステータス」を選ぶ
3.キャラクター選択状態になる
4.右キーを押す
5.ピクチャフォルダの「Actor2_4」が読み込まれ、表示される

ですが実際に実行してみると、一回目に右キーを押したときは何も表示されません。
それではともう一度右キーを押してみると、ようやく画像が表示されます(環境によって左右されるかも)。

一回目に失敗するのはなぜかと言うと、this.contents.bltを実行したときにはまだロードが完了しておらず、完了していないままの情報をbltメソッドに渡してしまっているからです。
で、描画に失敗します。
二回目に成功するのは、すでにロードが完了しており、キャッシュが作成され、正常な情報をbltメソッドに渡せているからです。

一回目で画像を表示してほしい

当然のことながら「右キーを叩いたなら、一回目であってもちゃんと読み込んで欲しい」と思う方が多いでしょう。
ではどうすればいいかと言うと「ロードが完了したあとでbltメソッドを実行」すればよいです。
BitmapクラスにはそのためのaddLoadListenerメソッドが用意されています。
このメソッドは、ビットマップの読み込みが完了したときに呼び出す関数を登録することができます。
先ほど作成したコードを改善してみましょう。

変更点はもちろんaddLoadListenerの部分です。
bltメソッドを即時実行するのではなく、addLoadListenerのコールバック関数の中で実行しているのがわかるかなと思います。
つまりロードが完了するのを待ってからbltメソッドを実行していることになります。
これはまさに非同期的な動きです。

補足*
非同期=マルチスレッドではありません。
実際、JavaScriptはシングルスレッドで動作します(ただしWebWorkerは別スレッドで起動する)。
この辺の話はググったらたくさん出るので、そちらを参照するのがよいかなと思います。

今どきの非同期処理

さて本題はここからです。
RPGツクールMVも1.6.1になり、晴れてasync/awaitが使えるようになりました(Promiseやジェネレーターは一応前から使えた)。
ちゅーわけでいちいちコールバック関数を登録しなくても使えるようにしましょう。

見てほしいのはImageManager.loadBitmapAsyncです。
これは戻り値としてPromiseを返します。
中身も単にロードが完了した段階でresolveし、bitmapを返すようにしているだけです。

下の2つはloadPictureとloadSystemの非同期版ですね。
ではこれらを使って、Window_MenuStatus.prototype.cursorRightを改造していきます。
コードをドン。

ゲームを実行してみてください。
右キーを一度押すだけで画像が表示されているかと思います。

loadPictureAsyncの解説

コンソールログを見てください。
以下の順番で表示でされているかと思います。

1.cursorRight開始
2.loadActorPicture開始
3.ここまでは同期的に進む
4.cursorRight終了
5.await完了
6.loadActorPicture終了

もしもloadPictureAsyncが同期的に実行されるなら、この表示順がおかしいことに気がつくかと思います。
つまり同期的に(上から順番に)実行されるなら、以下のようになるはずなのです。

1.cursorRight開始
2.loadActorPicture開始
3.ここまでは同期的に進む
4.await完了
5.loadActorPicture終了
6.cursorRight終了

ですが実際には最初に示した順番でコンソールが表示されています。
つまり、loadActorPictureが全て実行されるよりも先にcursorRightメソッドが終了していることになります。
まさに非同期的な動きですね。

async/await

ツクラーにはあまり馴染みがない気がするので、async/awaitについても少しだけ説明します。
非同期関数(async functionキーワード)は暗黙的にPromiseを返すことになっています(ただ今回の記事では投げっぱなしで結果を受け取ってないことが多いです。これはもともとあるツクールの関数をasyncにしたくなかった、という事情もあります)。
asyncキーワードのついた関数はawaitキーワードが現れると、その場でいったん停止し、Promiseの結果を待ちます
Promise内でresolveなりrejectなりが実行されると、awaitはその結果を受け取ります(Promise型を受け取るわけではないことに注意。あくまでも結果)。
そして停止していた箇所から処理を再開されます。
この停止と再開にはイベントループを利用しているっぽいです。

async/awaitについての詳細は色々なサイトが解説しているので、そちらを見てもらうのがよいかと思います。

永遠にawaitから進まない場合

先ほどのコードは以下のようになっていたかと思います。

もしもこのresolveが実行されなかった場合、どのようなことが起こるのでしょうか?
言い換えれば、Bitmapのロードが完了しなかった場合はどのようなことが起こるのでしょうか?
試してみましょう。
コードを以下のように変更してください。

これでこのコードは永遠にresolveすることはありません。
ゲームを実行し、先ほどと同じ要領でピクチャを表示させます。
コンソール画面には以下のようなログが表示されるかと思います。

1.cursorRight開始
2.loadActorPicture開始
3.ここまでは同期的に進む
4.cursorRight終了

ご覧の通り、「await完了」と表示されていません。
これはawaitの部分で処理が止まってしまっていることを意味します(ただし非同期処理なのでゲーム自体は動く)。
何らかの理由でresolveされないことがあるかもしれないので、rejectあたりもちゃんと実装してあげるのがいいかもしれませんね(エラーで落としたり)。

async/awaitは万能ではない

async/awaitを使うと、随所でaddLoadListenerを使う必要がなくなることがわかりました。
コールバック関数を使わないことにより、ネストも深くはなりません。
いいことばかりのように思います。
ですがそもそもRPGツクールMVのコアスクリプトはasync/awaitの使用を想定して作られてはいません。
つまり「使うと罠にはまるかもしれない」箇所や「使っても徒労に終わるだけ」な箇所があるのです。
その例をここからは見ていきます。

まずは以下のコードをプラグインとして読み込んでください。

systemフォルダにウィンドウ用の画像として、デフォルトのWindow.pngとは別にWindow2.pngを用意してください(素材屋さんから借りてくるのが手っ取り早いと思います)。
さてゲームを実行してみてください。
特に何ごともなくWindow2に合わせてウィンドウが表示されたかに思えますね。
プログラムの実行順は以下の通りです。

1.loadWindowskin実行
2.overrideWindowskinAsync開始
3.loadWindowskin実行終了
4.overrideWindowskinAsync終了

ではメニュー画面を開いてみてください。
なにか画面にちらつきがあるようには思えませんでしょうか?
これはWindowからWindow2にスキンが変更・適用されたためです。
実際、デフォルトのままなら画面のちらつきは発生しないはずです。

「じゃあ、そもそもWindowを読み込まないようにすればいいのでは?」
「つまりloadWindowSkinを完全に上書きしてしまえばいいのでは?」

実際にコードを書いてみましょう。

これで完全に上書きされます。
ゲームを実行してみてください。
おそらくはエラーが発生したかなと思います。

これは「windowskinを設定するよりも早くwindowskinを利用しようとした(未定義の状態)から」です。
実はwindowskinは単にスキンを設定しているだけでなく、文字色なんかを決めるのにも使われています。
このときにwindowskinを参照しようとするのですが、非同期処理でのスキンの読み込みが終わっていない状態で参照するためにエラーが発生するのですね。

「じゃあ、そもそもスキンなんて非同期処理で読み込まなくてよくね?」
「だって別にbltするわけでもないし」

試してみましょう。

おそらくは文字色が黒で潰れてしまっているのではないかなと思います。
これはロード未完了の状態で文字色を取得しようとし、結果として黒色が返されているためです(シーンを遷移するなどしてもう一度ウィンドウを開きなおすと、キャッシュされているため正常な色が表示される)。
なお、非同期処理バージョンで黒色ではなく白色で表示されていたのは、読み込み済みのWindow.pngから色を取得していたからであり、非同期処理は関係がありません。
文字の描画のタイミング時点では非同期処理が終了していないのです(つまり非同期処理させる意味はない。試しにWindow.pngではなくWindow2.pngをデフォルトスキンとして設定し、Window3.pngを非同期処理で読み込ませてみると文字が黒く塗りつぶされるのがわかる)。

結局のところ非同期処理ではうまくいかないことだらけなので、例えばScene_Boot.prototype.loadSystemWindowImageでウィンドウとして使いたい画像を読み込んでおくのが吉ではないでしょうか。
以下のような感じ。

同期的な処理ですが、全ての問題をクリアしています。
わざわざ非同期処理でややこしいことをする必要はありませんでした(また役にも立っていない)。
まさにKISS原則の通りです。
Keep it simple, stupid.

注*なお、ここではScene_BootでWindowのスキンを読み込みましたが、よりよい方法があるかもしれません。

その他の例

250ミリ秒に一度カウントを増やし、このカウントが10以上になったらresolveする処理を考えてみます(こっちのがよくあるasync/awaitのサンプルコードかも)。
というわけでコードをドン。

cursorRight開始
カウントアップ開始
cursorRight終了
count:0
count:1
count:2
count:3
count:4
count:5
count:6
count:7
count:8
count:9
count:10
カウントアップ終了

loadBitmapAsyncの例とあんまり変わりませんね。
実際に使うとすれば、100ミリ秒くらいの間隔で何らかの条件文を判定し続け(isLoaded的なの)、条件を満たせばresolveし、カウントが一定値(例えば10回)を超えても条件を満たさなければrejectする、なんかですかね。
この辺は実際に組んでみないとなんとも言えない気がしました。
ツクールで使えそうな何かありますかねえ……。

結局ツクールでは

以上みてきたように、async/awaitをツクールでうまく使いこなすのは結構たいへんです。
ここまで書いておいてなんですが、async/awaitは無理して使わなくてもいいんじゃないかなーと思いましたw
僕もプログラマとしての教養としてJavaScriptのasync/awaitについて勉強した感が強いです。
同期処理、サイコー!

終わりに

最初にも書いた通り「Unity 非同期完全に理解した勉強会」というものが最近おこなわれていました。
僕は行けていないのですが、公開された資料には全て目を通しました。
Unityはもともとコルーチンによる非同期処理(これもJavaScriptライクなシングルスレッド)をサポートしているのですが、現在ではUniRxやasync/awaitを使った非同期処理に置き換わろうとしている、あるいは同居しようとしているみたいです。
Unityはマルチスレッドもいける口なので、マルチスレッドで気をつけなければならない話はありますが、それでもJavaScriptのasync/awaitと共通する部分も結構あるのだなあと改めて感じました。

今年の目標である「非同期処理(とUniRx)を使えるようにする」という目標は徐々に達成しつつあります。
とは言えまだまだ理解が追いついていない部分も多いと自覚しているので、引き続き調べていきたいと思いますよ。
ほなそんな感じでまた。

フォローする