概要
前回までにメモリの関係と画像を説明しました。今回はスプライトを実際に利用して描画する方法を説明したいと思います。
スプライトとは?
メモリ上に確保されている仮想的な画面です。lcdやM5.Lcdなどとほぼ同じ関数が利用できます。実際のlcdなどに描画した場合には、実はどのタイミングで画面に反映されているのかがわかりません。

上記のように画面クリアをされた直後に反映された場合には、一瞬画面が消えてその後描画される様子が見えてしまいます。この動作が画面がちらつく原因になっています。

そこで、上記のようにメモリ上のスプライトに逐次描画し、描画が完成してから画面に一括転送することが可能です。一括転送中に画面に反映された場合にもおかしな状態で描画はされてしまいます。

上記のように左上から右下に対して転送をしていくイメージです。この場合でも転送途中のものが反映されて中途半端な表示はされてしまいます。

真ん中の黒い四角が移動した次の画面の例です。黄色のまるで囲った場所まで左上から転送が終わっています。スプライトを使ってもこのようにいつ反映されるかわからない問題は残ります。
ただし、直接画面に描画すると背景の赤で塗り潰した画面が表示されてから、黒い四角が描画されますので、確実にチラツキが感じられると思います。
スプライトの用途
スプライトの用途には複数あります。用途別に解説をしたいと思います。
チラツキ防止
広範囲に動きが多いものを描画する場合、直接画面に描画すると冒頭で紹介したとおりチラツキが発生します。そこで一度スプライトに描画してから一括転送をすることでチラツキを低減させることが可能です。
ただし、問題がありましてM5StickCなどの小さい画面の場合にはよいのですが、M5Stackなどの画面の場合、メモリ量が足りないので画面全体のスプライトを確保することができません。
詳細は前々回にまとめた内容になります。回避方法などはのちほど解説したいと思います。
素材置場
本来スプライトはこちらを指すほうが一般的な用語だと思います。アイコンや画像などを読み込んでおき、描画を行います。シューティングゲームなどで自機や敵のことをスプライトと表現することが多いと思います。
上記でも紹介していますが、RGB565画像をスケッチに埋め込む場合にはpushImage()関数で直接可能ですので、スプライトを利用する必要はありません。
変形可能素材置場
スプライトの利点は変形できるところにあります。
- 拡大・縮小
- 回転
- アンチエイリアス
- アフィン変換
上記みたいなことが実現可能です。
Core2が来たのと、M5stackFireの中にLovyanGFXのアフィン変換を利用して3Dの箱に絵を貼り付けてみたがチラつきまくるものができた記念動画。よし、まずはご飯だ… pic.twitter.com/Rk22wTHX9n
— Y6 (@SourPomeloY6) October 31, 2020
他の人の例で恐縮ですが、上記は3D表示に対してアフィン変換でテクスチャを描画しています。直接LCDに描画している例なのでちょっとちらついていますね。ちらついていない例もあるのですが、チラツキの説明にもなるので、ちょっと古い例を埋め込ませていただきました。
とはいえ、アフィン変換はちょっとむずかしいので私も使ったことがありません。。。
実際の使い方
宣言方法
#define LGFX_AUTODETECT #include <LovyanGFX.hpp> static LGFX lcd; // LGFXのインスタンスを作成。 static LGFX_Sprite sprite(&lcd); // lcdに描画するスプライト作成 static LGFX_Sprite sprite2(&sprite); // spriteに描画するスプライト作成 static LGFX_Sprite sprite3; // デフォルト描画先設定がないスプライト作成 void setup() { lcd.init(); } void loop() { }
上記のようにLGFX_Spriteクラスで変数を定義します。この時点ではクラスの箱だけで実際の描画バッファは確保されていません。
&で渡している値が、デフォルトの描画先になります。無指定でも構いませんが、描画時に指定をする必要があります。

上記の関係になります。spriteが無指定で描画をしたい場合にはlcdになります。スプライトからスプライトに描画することもできます。
単純な関係の場合には宣言時に指定したほうがわかりやすくなると思いますが、複雑な使い方をする場合には描画時に指定したほうがわかりやすいと思います。
sprite.pushSprite(0, 64);
上記の場合にはspriteのデフォルトはlcdなので、以下の指定と同じになります。
sprite.pushSprite(&lcd, 0, 64);
このように第一引数で描画先を指定できます。
描画例
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); sprite.pushSprite(0, 0);
大きさの異なるスプライトを3つ作成し赤、青、緑に塗ってから描画した例です。

描画結果は上記になります。
緑のsprite3からsprite2に描画。sprite2からspriteに描画。spriteからlcdに描画されています。さて、注意点ですがsprite3は宣言時にデフォルト描画先が指定されていません。
sprite3.pushSprite(0, 0);
上記のように、描画先を指定せずに実行するとリセットがかかるので気をつけましょう!
スプライトへの基本描画
スプライトも画面も同じ描画クラスから派生していますので、基本的には同じことが可能です。上記で説明したライン命令などの基本描画の他にテキスト描画なども可能です。
特殊描画系
pushSprite() 別画面に描画
先程使いましたが、他の画面にそのまま全体描画する場合にはpushSprite()関数を利用します。左上座標の他に、透過色の指定もあります。
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); sprite.pushSprite(0, 0); sprite.pushSprite(64, 0, TFT_RED); // 赤を透明色に指定
先ほどの最後に横64ドットずらして、赤色を透明色に指定して描画しています。

すると赤色を除いて他の色のみ描画されています。四角以外の画像データを背景ありの場所に描画する場合には透明色指定を使ってみてください。
pushRotateZoom() 回転と拡大縮小
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); sprite.pushSprite(0, 0); sprite.setPivot(sprite.width()/2.0, sprite.height()/2.0); sprite.pushRotateZoom(120, 120, 45, 2.0, 3.0);
setPivot()で回転する場合の原点を指定します。初期値は左上の(0, 0)だと思いますが、画像の中心で回したいので横幅と縦幅の半分を指定しています。
pushRotateZoom()で画面上の(120, 120)の座標に45度右回転し、横2.0倍、縦3.0倍した画像を描画しています。

実行結果が上記です。spriteの原点部分が画面上の(120, 120)になっていますね。ちなみに今回は指定していませんが透明色も指定できます。
pushRotateZoomWithAA() アンチエイリアス付き回転と拡大縮小
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); sprite.pushSprite(0, 0); sprite.setPivot(sprite.width()/2.0, sprite.height()/2.0); sprite.pushRotateZoomWithAA(120, 120, 45, 2.0, 3.0);
pushRotateZoom()の変わりにpushRotateZoomWithAA()を使うと、アンチエイリアス付きの描画になります。

ちょっとこれだけだとわかりにくいですね。拡大してみます。

エッジの部分がアンチエイリアスがかかっていますね。

通常描画の場合にはエッジが見えています。どっちのほうがきれいかは状況によるので、使い分けてみてください。ただし、アンチエイリアスは結構重い処理になりますのでご注意を!
pushAffine() アフィン変換描画
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); float matrix[6] = {2.0, // 横2倍 -1.0, // 横傾き 100.0, // X座標100 0.1, // 縦傾き 3.0, // 縦3倍 10.0 // Y座標10 }; sprite.pushAffine(matrix);
指定が難しいです、、、本当は行列計算でパラメーターをセットしたほうがいいのかな?

上記みたいな出力になりました。傾きは1でこれだけ傾くってことは、度ではなくてラジアンですね。
pushAffineWithAA() アンチエイリアス付きアフィン変換描画
sprite.createSprite(64, 64); sprite2.createSprite(32, 32); sprite3.createSprite(16, 16); sprite.fillScreen(TFT_RED); sprite2.fillScreen(TFT_BLUE); sprite3.fillScreen(TFT_GREEN); sprite3.pushSprite(&sprite2, 0, 0); sprite2.pushSprite(0, 0); float matrix[6] = {2.0, // 横2倍 -1.0, // 横傾き 100.0, // X座標100 0.1, // 縦傾き 3.0, // 縦3倍 10.0 // Y座標10 }; sprite.pushAffineWithAA(matrix);
こちらも関数がpushAffineWithAA()に変わっただけです。

アンチエイリアス付きで出力されていますね。

拡大してみました。
※(追記)今回本文で利用している画面キャプチャですが、取得ロジックがおかしかったようで少しおかしな画像になっています。本当は上記の斜めのエッジはもう少しきれいに表示されています。
まとめ
アフィン変換難しい、、、
さて、今回は基礎だけで終わってしまいました。次回以降にもう少し突っ込んだ高速化とメモリマネージメントのノウハウを紹介したいと思います。
コメント
いつも有益な情報をありがとうございます。
以前、USBケーブルと外すと動作しないことがある件で質問した
hsenshuです。
先日、M5Unifiedを使用しM5StickCPlus2で動作しているプログラムを
M5Stack Greyに移植しました。しかし、画面に全く表示されず、困って
いました。試しに canvas表示を通常の記述に変更すると表示されます。
ふと、Spriteの生成に失敗しているのでは、と考え検索したところ、こちら
で以下の記述を見つけました。
> ただし、問題がありまして M5StickC などの小さい画面の場合にはよい
> のですが、M5Stack などの画面の場合、メモリ量が足りないので画面全
> 体のスプライトを確保することができません。
その通りでした。生成するサーズをM5StcickC並に縮小すると、何事も
なかったように動作します。
でも、失敗しているならそれなりに通知があれば悩まなかったのに、と
思いました。
M5StackやM5Stick用のプログラムは、思ったように動作しないときに
手がかりが少なく、貴殿のような解説ページは貴重な存在です。
とても参考になりました。
お役に立ててよかったです
本来はcreateSpriteの戻り値で成功したかがわかるのでチェックしたほうがいいですね!
お返事、ありがとうございます。
createSprite関数は void * 型で、エラー時は nullptrを返すようです。
そこで、以下のようにコードを修正しました(エラー時は停止したほうが
悩みは少ないのです)。なお、200, 240程度のサイズならOKで、240, 240
ではNGでした。
// canvasサイズ設定(画面サイズに設定)… 大きすぎると動作しない
void *r = canvas.createSprite(135, 240);
if ( r==nullptr) {
Serial.printf(“### createSprite() … failed\n”);
while(1) {
delay(100);
}
}
if (canvas.width() == 0)
上記とかでもいいかもしれません
作成に失敗するとサイズは0に初期化されているはずです
ありがとうございます。
先のコードの一部を
> if (canvas.width() == 0)
に変更して、エラーになるサイズを指定して試した
ところ、エラーを検出できました。
この方が変数宣言も不要でスマートだと思います。
動いてよかったです。
if (canvas.createSprite(135, 240) == nullptr) {
// 作成エラー
}
おそらく上記が一番標準的な気もします
canvas.createSprite(135, 240);
if(canvas.width() == 0){
// 作成エラー
}
個人的にはこっちが好きですが、どちらでも問題ないと思います!
公開されているコードを調べてみました。
createSprite()の戻り値を気にしていないのが大多数ですが、ライブラ
リーでは以下のように書かれていました。
lgfx\v1\LGFX_Sprite.cpp(708): if (!createSprite(w, h)) return false;
!演算子は 「==nullptr」と同じ働きがあり、スマートな記述と感じました。
色深度を8bitカラーに指定したところ、320×240でも通る
ことを確認しました。これでも、アプリの指定する色は
使えるので、制限はありません。
// canvasサイズ設定(画面サイズに設定する)
// 大きすぎると失敗する事があり、エラーチェックを追加した
// 256色なら成功する
canvas.setColorDepth(8);
if (!canvas.createSprite(320, 240)) {
sprf(“### createSprite(%d, %d) … failed\n”, 320, 240);
while(1) {
delay(100);
}
}