🕹️

M5Stack でゲームを作る 003

2022/06/09に公開

前回の記事

前回はシンプルな描画でブロック崩しを作りました。
今回は画像と音声リソースを使用して豪華にしていきます。

その前にヒープメモリについて

諸々のリソースを使う上で考えなければならないのが、ヒープメモリ(動的確保メモリ)です。リソース読み込みの際のバッファや、 LGFXSprite 等が内部確保するメモリに留意して、メモリ不足による動作不能を避けなければなりません。
M5Stack Basic/Gray は 520KB の SRAM 領域を持っています。SRAM は IRAM / DRAM 領域に分割され、それぞれの目的の為に使用されます。

IRAM SRAM
コード(text data)
32bit align 特殊ヒープ
未初期化データ(BSS)
データ
ヒープ

IRAM 側のヒープは通常では使用しません。
プラットフォームのバージョンによって変わりますが、setup() 時には 大体 270KB 程度のヒープ領域が使えます。M5.begin() 後のヒープ領域は与えられた引数やバージョンによって変動します。
また、BT が有効な場合は DRAM にその為の領域が確保されます。 BT 機能を使用しないのなら setup() でその領域を解放する事で 約 19 KB かそれ以下のヒープを増やせる場合があります(バージョンによって効果がない場合があります)。

関連資料

メモリモデルに関しては ESP32 Programmers’ Memory Model 、ヒープメモリ管理に関してはたなかまさゆき氏のLang-ship ESP32のヒープメモリ管理 その1 が参考になります。。
なお通常は malloc() / new[] で十分だと思います(内部的には ESP-IDF 経由で適切な API によって確保されます)

残りヒープメモリ容量

以下のように esp_get_free_heap_size() を使って取得できます(先述の BT が使用している領域解放もしています) 。

main.cpp
// 略
#include <esp_system.h> // esp_get_free_heap_size
#include <esp_bt.h> // esp_bt_...
#include <esp_bt_main.h> // esp_bluedroid_...
// 略
void setup()
{
    auto mem0 = esp_get_free_heap_size();

    // BT 機能の使用停止と使用ヒープ領域解放
    esp_bluedroid_disable();
    esp_bluedroid_deinit();
    esp_bt_controller_disable();
    esp_bt_controller_deinit();
    esp_bt_mem_release(ESP_BT_MODE_BTDM);
    auto mem1 = esp_get_free_heap_size();

    M5.begin(false /* LCD */, false /* SD */ , true /* Serial */);
    Wire.begin();
    while(!Serial){ delay(10); }
    auto mem2 = esp_get_free_heap_size();

    Breakout::instance().setup(&lcd);
    auto mem3 = esp_get_free_heap_size();

    printf("Heap available : %u : %u : %u : %u\n", mem0, mem1, mem2, mem3);
}
// 私の環境 espressif32@3.4.0 m5stack/M5Stack@0.4.0 での例
// # Heap available : 277760 : 282752 : 282012 : 121084 

アプリケーション初期化終了時に約 118KB とやや心許ないですね。これを増やしつつ描画部分を改良していきます。

LovyanGFX

https://github.com/lovyan03/LovyanGFX
LovyanGFX とソフトウェアスプライトについては

オフスクリーンバッファとDMA転送

詳しくはこちらを参照してください。
M5StackでLovyanGFXを試す MovingIconsスケッチの分析

Lang-ship の LovyanGFX入門 その9 描画速度計測 から引用

ざっくりした指針として、M5Stackの320×240の画面を秒間30フレームで描画する場合には、1フレームあたり33ミリ秒で描画する必要があります。
オンメモリに320×240のスプライトは確保できないので320×80のスプライトを2枚確保して、交互に描画をしながら上、中、下と3回にわけて転送をします。
320×80のDMA転送時間が約10ミリ秒なので、次の描画を10ミリ秒(10,000マイクロ秒)で準備できれば秒間30フレームで描画可能です。

前回まではオフスクリーンスプライトとして 320 x 120 16bit color を 2 枚使用し、2 回描画していました。内部のピクセルデータとして 320 x 120 x 2byte x 2 = 153600B(150KB) がヒープより確保されている事になります。
オフスクリーンスプライトの高さに反比例して描画回数が増えていきます。全体の処理速度とのバランスで適切な落とし所を見つけましょう。
複数回の描画で処理速度的には遅くならないか? と思いきや、 CPU 処理中に DMAでの LCD への転送が行われる為、総合的に高速化されます。

app.cpp
constexpr std::uint32_t SPLIT = 6; // 6 分割 (オフスクリーンスプライトの高さは 240/6 = 40)
void Breakout::render()
{
    const std::size_t tlen = _lcd->width() * sprite_height; // DMA 転送サイズ
    std::int_fast16_t y = 0;
    for(std::int_fast16_t i = 0; i < SPLIT; ++i)
    {
        _flip = !_flip;
        goblib::lgfx::GSprite* s = &_sprites[_flip];
        s->clear();
        // 略 s に対して 諸々描画
        // DMA 転送開始
        _lcd->pushPixelsDMA(static_cast<::lgfx::swap565_t*>(s->getBuffer()), tlen);
        y += sprite_height;
    }

色数とメモリ

M5Stack の液晶は 1 pixel 16bit で表現されますが、スプライトは以下の任意の色深度を利用できます。

色深度 (bit) 色数 パレット使用? 320 x 240 pixel でのイメージバイト数
パレットを使う場合は色情報がさらに必要
1 2 使う 9600 (9.375K)
4 16 使う 38400 (37.5K)
8 256 使う 76800 (75K)
16 65536 使わない 153600 (150K)
24 16777216 使わない 230400 (225K)

色深度が大きいほど表現力は増しますが、メモリ使用サイズが肥大化します。とはいえ最終出力先が 16bit color ですので、それ以上の色深度を持たせても減色されることに注意しましょう。
またソフトウェアスプライトである為に透過色を 1色 用意する必要があるので、各リソースで使用可能な色数は 色数 -1 となります。
オフスクリーンスプライトとして使用しているのは、液晶と同様の 16 bit カラーです。色深度が違うスプライトからの pushImage は内部で色変換された上で反映されます。
メモリと表現のバランス的には 4 bit か 8 bit をリソースとして使うのが良さそうです。ここでは 4bit をリソースとして使うことにします。

画像リソース

準備

まずは 4 bit Bitmap 画像を任意のツールや変換ツール等を駆使して用意します。
ちなみに私は Mac 上で Piskel で png でエキスポートして、 ImageMagick で bmp にしています。

ファイルの形式確認 ( Mac )

bitsPerSample が 4 である事を確認してください。

sips -g all foo.bmp

  pixelWidth: 96  # 幅 
  pixelHeight: 48 # 高さ
  typeIdentifier: com.microsoft.bmp
  format: bmp # フォーマット
  formatOptions: default
  dpiWidth: 72.000
  dpiHeight: 72.000
  samplesPerPixel: 3
  bitsPerSample: 4 # 4bit bitmap
  hasAlpha: no
  space: RGB

透過色パレットの 0 固定化

パレット順を任意に編集できるツールを使用している場合は問題ないのですが、そうでない場合透過色のパレット番号が保存や変換の過程でバラバラの位置になってしまう場合があります。透過しつつ描画の場合、透過色のパレット番号を指定する必要がありますが、これを固定化しておくと今後の為にも便利です。
ここでは 0 番のパレットを必ず透過色とします。

拙作の reorder_palette.py(要 python3 ) で、パレットの並び替えができます。

python3 reorder_palette.py input_4bit.bmp output_4bit.bmp

文字フォント

ゲームにつきものの固定幅高さのフォントを作成しましょう。
表現する範囲は ASCII のスペース( 20H / 32D ) からアンダースコア( 5FH / 95D )の 64 個とします。
LovyanGFX では二値(0 or 1)のイメージとグリフ情報からなる GFXFont を作る事で描画できます。
フォントは SD 等の外部デバイスから読み込まず、ソースファイルに埋め込みます。これは SD から読み込めなかった場合でも当該フォントで表示できるようにする為です。

イメージ作成

直接ソースを書いても良いですが、ツール等で作成した画像から作成できた方が楽ですね。
拙作の make_lgfxbmpfont.py (要 python3 ) を使うと画像からソースを吐き出してくれます。


図A : 必死で作った自作フォント(自由に利用して構いません)

画像ファイルを用意して、 make_lgfxbmpfont.py に処理させます。リダイレクトして任意のファイルに吐き出します。

python3 make_lgfxbmpfont.py --width 8 --height 8 --name myfont gob_88.png > myfont.cpp
myfont.cpp
#include <LovyanGFX.hpp>
const uint8_t myfont_bitmaps[] PROGMEM = {
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,	// ' '
    0x18,0x18,0x18,0x18,0x00,0x00,0x18,0x18,	// '!'
    0x66,0x66,0x66,0x22,0x00,0x00,0x00,0x00,	// '"'
    // 中略
    0x78,0x78,0x18,0x18,0x18,0x18,0x78,0x78,	// ']'
    0x18,0x3c,0x66,0x66,0x66,0x00,0x00,0x00,	// '^'
    0x00,0x00,0x00,0x00,0x00,0x00,0x7f,0x7f,	// '_'
};
const ::lgfx::GFXglyph myfont_glyphs[] PROGMEM = {
    { 0, 8, 8, 8, 0, -8 },	//' '
    { 8, 8, 8, 8, 0, -8 },	//'!'
    { 16, 8, 8, 8, 0, -8 },	//'"'
    // 中略
    { 488, 8, 8, 8, 0, -8 },	//']'
    { 496, 8, 8, 8, 0, -8 },	//'^'
    { 504, 8, 8, 8, 0, -8 },	//'_'
};
const ::lgfx::GFXfont myfont_font PROGMEM = { (uint8_t*)myfont_bitmaps, (GFXglyph*)myfont_glyphs, 0x20, 0x5f, 8 };

ヘッダかソース、どちらの形式で利用するかで手を加える必要があります。今回は ソース(.cpp) として吐いているので、

myfont.cpp
static const uint8_t myfont_bitmaps[] PROGMEM = {
// 略
static const ::lgfx::GFXglyph myfont_glyphs[] PROGMEM = {
// 略
extern const ::lgfx::GFXfont myfont_font PROGMEM = { (uint8_t*)myfont_bitmaps, (::lgfx::GFXglyph*)myfont_glyphs, 0x20, 0x5f, 8 };

として、利用する側ではこんな感じとなります。

app.cpp
extern const ::lgfx::GFXfont myfont_font;
// 略
        for (std::uint32_t i = 0; !fail && i < 2; ++i)
        {
            _sprites[i].setColorDepth(_lcd->getColorDepth());
            _sprites[i].setFont(&myfont_font); // 適用
            _sprites[i].setTextColor(0xFFFFFFU);
            fail = !_sprites[i].createSprite(_lcd_width, sprite_height);
        }
// 略


図B : 前回のプロック崩しに自作フォント適用

ボール、パドル、ブロック、背景

今回は全て同時に使用される上に、総サイズも小さいので一つのビットマップとして準備します。
ボールは8 x 8、パドルは 32 x 8、ブロックは 16 x 8、背景用パーツは 8 x 8 のパーツを組み合わせて使用します。
アニメーション表現のため、複数回で破壊されるブロックと、破壊されないブロックはアニメーションパターンを用意しました。


図C : 画像リソース

スプライトの部分描画

LovyanGFX では pushImage で 他のスプライトに描画できますが、描画先座標と範囲は指定はあるものの、元の位置と範囲の指定はありません。そこで派生クラスを作成して部分描画ができるに拡張しました。但しアフィン変換は適用できません。

lgfx/gob_lgfx_animated_sprite.hpp
//略
class CellSprite : public GSprite
{
  public:
    explicit CellSprite(GLovyanGFX* parent = nullptr) : GSprite(parent) {}
    virtual ~CellSprite() {}

    template<typename T> GOBLIB_INLINE
    void pushCell(GLovyanGFX* dst, const CellRect& r, std::int32_t x, std::int32_t y, const T& transp)
    {
        push_cell(dst, r, x, y, _write_conv.convert(transp) & _write_conv.colormask);
    }
    GOBLIB_INLINE void pushCell(GLovyanGFX* dst, const CellRect& r, std::int32_t x, std::int32_t y)
    {
        push_cell(dst, r, x, y);;
    }
//略

CellSprute cs;
GSprite output;
CellRect cr(32,8, 16, 8); // x, y, width, height
cs.pushCell(&output, cr, 128, 256, 0); // cs の (32,8) - (47, 23) の範囲を output の (128, 256) へ 透過色 0 で描画

スプライトのパラパラアニメーション

gob_lib の gob_animation.hpp にある AnimationSequencer を使って単純なパラパラアニメーションを定義します。
金色の破壊されないブロックのアニメーションの場合の例を示します。

  1. アニメーションパターンの CellRect 配列を定義します。
#define WIDTH (16)
#define HEIGHT(8)
const CellRect animationCell[] = 
{
    goblib::lgfx::CellRect(WIDTH * 0, HEIGHT * 3, WIDTH, HEIGHT),
    goblib::lgfx::CellRect(WIDTH * 1, HEIGHT * 3, WIDTH, HEIGHT),
    goblib::lgfx::CellRect(WIDTH * 2, HEIGHT * 3, WIDTH, HEIGHT),
    goblib::lgfx::CellRect(WIDTH * 3, HEIGHT * 3, WIDTH, HEIGHT),
    goblib::lgfx::CellRect(WIDTH * 4, HEIGHT * 3, WIDTH, HEIGHT),
    goblib::lgfx::CellRect(WIDTH * 5, HEIGHT * 3, WIDTH, HEIGHT),
};
  1. 配列の何番目をどの程度描画し続けるのか、AnimationSequencer を定義します。
    goblib::graph::Sequence コンストラクタは与えた型によって解釈が異なるので厳密に一致させる為、 uint8_t (suffix _u8) を使って型変換を行っています。
#include <gob_suffix.hpp>
using goblib::suffix::operator"" _u8; // uint8_t 型であることを表すユーザー定義接尾子
using Seq = goblib::graph::Sequence;
goblib::graph::AnimationSequencer animation =
{
    // 命令, 値(CellRect 配列インデックス), 表示期間
    Seq(Seq::Draw, 1, 3_u8), // animationCell[ 1 ] を 3 frame 表示
    Seq(Seq::Draw, 2, 3_u8), // animationCell[ 2 ] を 3 frame 表示
    Seq(Seq::Draw, 3, 3_u8), // animationCell[ 3 ] を 3 frame 表示
    Seq(Seq::Draw, 4, 3_u8), // animationCell[ 4 ] を 3 frame 表示
    Seq(Seq::Draw, 5, 3_u8), // animationCell[ 5 ] を 3 frame 表示
    Seq(Seq::Draw, 0, 1_u8), // animationCell[ 0 ] を 表示 (末尾なので以降は [0] が表示され続ける
};
  1. AnimationSequencer を実行し、対応する CellRect を取得し描画に使用します。
animation.pump();  // シーケンスの進行
cellsPrite.pushCell(outputSprite, animationCell[animation.cell()], dx, dy, 0); // animation.cell() は Seq() の値を返す


図D : 前回と今回の比較

SD カードアクセス

SdFat

https://github.com/greiman/SdFat

Arduino 上で SD カードアクセスを行うためのライブラリです。 M5Stack にも SD カードアクセスは搭載されているのですが、バージョンによって安定していません(特に 2.x )。
そこで SdFat を利用していきます。

ちなみにESP-IDF ardiono-esp32 M5Stack platform-espressif32 のバージョン対応はこちらが参考になります。
https://github.com/tanakamasayuki/esp32-arduino-test

goblib_m5s にて SdFat をラッピングしているので、それを利用します。もちろん直接 SdFat を使用しても問題ありません。

main.cpp
// 略
#include <gob_m5s_sd.hpp>
// 略
void setup()
{
    M5.begin(false /* LCD */, false /* SD */ , true /* Serial */); // LCD は LovyanGFX SD は SdFat を使用するので false
    Wire.begin();
    while(!Serial){ delay(10); }
    SdFat& sd = goblib::m5s::SD::instance().sd(); // SdFat のインスタンス
    while(!sd.begin((unsigned)TFCARD_CS_PIN, SD_SCK_MHZ(25))) { delay(10); } // 開始
}

LovyanGFX のスプライトを Bitmap ファイルから作成する場合はこんな感じになります。

bool createFromBitmap(goblib::lgfx::GSprite& sprite,const char* bitmap_path)
{
    goblib::m5s::File file;
    file.open(bitmap_path, O_READ);
    if(!file) { return false; }

    std::size_t len = file.available();
    if(len == 0) { return false; }

    auto bmp = new std::uint8_t[len]; // 一時バッファ
    if(!bmp) { return false; }

    bool b = false;
    if(len == file.read(bmp, len))
    {
        sprite.createFromBmp(bmp, len);
        b = true;
    }
    delete[] bmp;
    return b;
}

DMA BUS 競合

M5Stack (LCD ILI9342) では液晶とSDアクセスに同一の DMA BUS が使用されます。残念ながら排他的に使用しないといけないので、LCD への DMA 転送中や、LovyanGFX による DMA 占有中( LGFX::startWrite 呼び出し以降) は SD カードアクセスができません。(アクセスすると止まります)
呼び出し側で排他制御を行う必要があるので要注意です。

BGM,SFX

リソースの準備

任意のツールにてサウンドリソースを用意してください。メモリ使用域削減の為に品質は落ちますが、リニア PCM 、8bit 8000Hz モノラルをリソースの要件とします。ツール側で当該フォーマットを出力できない場合は FFMPEG 等で変換するのも手です。

(例) FFMPEG による 8bit モノラル 8000Hz への変換

ffmpeg -i foo.mp3 -ac 1 -acodec pcm_u8 -ar 8000 foo.wav

ファイルの形式確認 ( Mac )

リニア PCM 8bit モノラル 8000Hz である事を確認してください。

afinfo foo.wav

File:           foo.wav
File type ID:   WAVE
Num Tracks:     1
----
Data format:     1 ch,   8000 Hz, 'lpcm' (0x00000008) 8-bit unsigned integer # モノラル 8000Hz リニア PCM 8bit
                no channel layout.
estimated duration: 1.738250 sec
audio bytes: 13906
audio packets: 13906
bit rate: 64000 bits per second
packet size upper bound: 1
maximum packet size: 1
audio data file offset: 142
optimized
source bit depth: I8
----

goblib::m5s::Speaker を使用した鳴動

goblib::m5s::Speaker は M5Unified の Speaker_Class を移植拡張した class です。
Speaker_Class と機能互換ですが、

  • 同時再生数は 引数で与える
  • 再生タスク (CPU 0) は常時存在する
  • goblib::PcmStream を用いたストリーミング再生機能追加

といった違いがあります。

SD カードからのストリーミング再生

SFXの wav は小さい為メモリに乗せて再生が可能ですが、 BGM はメモリ上に全て読み込むのは厳しいです。そこで SD カード上の wav ファイルを少しずつ読み込みその部分を再生させる事でメモリ逼迫を防ぎます。

#include <gob_m5s_stream.hpp>
#include <gob_pcm_stream.hpp>
#include <gob_m5s_speaker.hpp>
goblib::m5s::FileStream file;
goblib::PcmStream pcm;
goblib::m5s::Speaker speaker(5 /* 同時再生数 */);
void setup()
{
    file.open("foo.wav"); // SD 上のファイル開く
    pcm.assign(&file); // Pcm streamに紐づける

	speaker.begin(); // speaker 始動
    speaker.play(pcm); // streaming 再生開始
}
void loop()
{
    _speaker.pump(); // SD カードから読み出して鳴らす
   delay(1000/30);
}

当プロジェクトでは SFX、BGM を鳴らす為の SoundSystem を構築しています。

DMA 競合回避

先述の通り、液晶への DMA 転送と SD カードアクセスの競合を避ける必要があります。
LGFX::endWrite の呼び出しで DMA を解放できますが、DMA 処理中の場合は終わるまで待たされてしまいます。FPS を落とさない為にも出来る限り DMA 転送が終わっているであろう位置で endWrite を呼ぶようにします。

今回のゲームでは Breakout::update() の処理が軽微で、この中では DMA 転送が終わっていません。
LGFX::dmaBusy() を用いて調査した所、 Breakout::render() の最初のオフスクリーンバッファへの描画が終わった辺りで、前回の render での DMA 転送が終わっていました。(描画量が少ない場合は終わっていない場合もあります)
これ以降ですと再度 DMA 転送が始まってしまうので、ここで SD カードアクセスをするようにします。
但し再生開始の際はファイルオープンの為の SD カードアクセスがある為、DMA 転送終了待ちを許容します。

app.cpp
void Breakout::render()
{
// 略 DMA 転送はまだ終わっていない
    for(std::int_fast16_t i = 0; i < SPLIT; ++i)
    {
        _flip = !_flip;
        goblib::lgfx::GSprite* s = &_sprites[_flip];

        s->clear();
        // 略 s への描画
        if (y == 0)
        {
            s->setCursor(0, 0);
            s->printf("FPS:%2.2f  SCORE: %08u REMAIN:%d", fps(), _score, _remain);
            //
            _lcd->endWrite(); // DMA 転送終了待ち(たぶん終わっている) と 解放
            SoundSystem::instance().pump(); // SD カードアクセス
            _lcd->startWrite(); // DMA BUS 占有
        }
        _lcd->pushPixelsDMA(static_cast<::lgfx::swap565_t*>(s->getBuffer()), tlen); // DMA 転送開始
        y += sprite_height;
    }
}
void Breakout::playBgm(BGM bgm)
{
    _lcd->endWrite(); // DMA 転送終了待ち と 解放
    SoundSystem::instance().playBgm(bgm);
    _lcd->startWrite();
}

SD カード上のファイル

リポジトリの res/bo2 を SDカードにコピーしてください。

Image

ファイル名 内容
bo2.bmp Bricks,Paddle,Ball,BG

BGM

ファイル名 内容
bgm1.wav In game
qq2_1.wav Start stage
qr2_1.wav Clear stage
qr2_2.wav Miss

SFX

ファイル名 内容
qq1_1.wav Hit
qq3_1.wav Hit 2

ソース

以上を踏まえてまとめたものがこちらになります。
https://github.com/GOB52/M5S_games/tree/master/breakout2

リソースを SD カードにコピーして M5Stack にセットするのを忘れないように!
音のリソースは OtoLogic (https://otologic.jp) CC BY 4.0 を使用しています。

https://twitter.com/GOB_52_GOB/status/1534822767497183232

Discussion