0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

M5Stack Core2にアニメーション付きキャラクターを表示してみた

0
Posted at

はじめに

                                       
Video Project 4.gif

M5Stack Core2 に、フルカラーのキャラクターの顔を表示し、まばたきと視線(傾けた方向・タッチした方向を見る)を付けた。

この記事では、それをどう動くところまで持っていったかをまとめる。

実行環境

  • ボード: M5Stack Core2(PlatformIO)
  • ライブラリ: M5Unified / M5GFX
  • キャラクター作成: Rive(好みで選んだだけで、パーツごとのPNGが書き出せればよい)
  • 素材の変換: Python(Pillow)

ソースコード

furuTra/m5Core-Avator

キャラクターアセット

顔は、パーツごとに分けた透過PNG(顔の土台、白目、瞳、眉、瞼、鼻、口、眼鏡、髪…)を重ねて作る。

下記のようにアセットパーツを作成して使用した。

parts_row.png

まず、PNGを毎フレーム描画してみる

最初は、アセットのPNGを毎フレーム、全パーツ奥から順に描き、画面へ転送した。

// イメージ
for (auto& part : parts)            // 奥から手前へ
    canvas.drawPngFile(fs, part.path, x, y);
canvas.pushSprite(0, 0);            // 画面へ転送

この方法では、実機でまともに動かなかった。理由は下記。

  • PNGの描画(drawPng)は、描くたびにPNGのデコードが走る
  • 12枚を毎フレームデコードして重ねるのは、現実的でない
  • 画面全体の転送を毎フレーム行うのも、視線の追従には重い

PNGを使うのではなく、画素をバイナリデータとして扱う

PNGをそのまま描画して動かすのは絶対無理。

という訳で下記のようにする。

  • 視線で動くパーツは、PNGではなくARGB値の配列として持ち、位置をずらして重ねる
  • 視線が動いたときは、画面全体ではなく目の周辺の矩形(バンド)だけを更新する

具体的には下記のように対応した。

1. 画像を縮小する

拡大描画は特に遅いので、PC側で表示したい大きさにあらかじめ縮小したPNGを作っておく。
デバイスでは常に等倍で描く。拡大率(顔を画面より少し大きく見せる倍率)も、実行時の設定ではなくPNGへ焼き込んでおくのがコツだ。

2. 動くパーツを配列にする

視線を動かすと、瞳・白目・眉・鼻・口・眼鏡が連動して、少しずつ同じ向きへ寄る。
これらを毎フレームPNGからデコードして描くと、非常に遅くなる。

そこで、動くパーツを画素ごとの色の配列(ARGB値) にしてデータとして持ち、位置をずらして画面へ重ねるようにした。

ここで、ARGB値とは、透明度(A)と色(R, G, B)をまとめた32bitの値 0xAARRGGBB で表し、画像の画素を順番に並べたもののことである。

w = 3, h = 2 のサイズの画像を例に説明する。画素を下記のようなindex順で1次配列に格納する。

画像 (x →, y ↓)         配列の index
 (0,0) (1,0) (2,0)       0  1  2
 (0,1) (1,1) (2,1)       3  4  5

(つまり、切り出した矩形の幅 w 個ごとに、1行ぶんのARGB値が格納されていることになる。)

これを.bin形式のバイナリファイルとして書き出しておき、起動時にPSRAMへ読み込む。デコードは不要である。

pushAlphaImage(x, y, w, h, argb) にこの配列を渡せば、M5GFX が画素ごとに透明度を考慮して重ねてくれる。

2-1. PCで配列ファイルを作る

実際にバイナリファイルを作るにあたり、専用のPythonスクリプトを用意した。

Pythonスクリプトで、素材PNGから次の4ステップで配列ファイルを作る。

① 表示サイズへ縮小する

素材を表示したい大きさへ縮小する(「1. 画像を縮小する」と同じ)。

② 一緒に動くパーツを、1枚に合成する

白目と眉は、視線がどちらへ向いても、同じ向き・同じ距離だけ動く。鼻・口・眼鏡も同様だ。

このように常に同じ量だけ一緒に動くパーツをまとめて「グループ」と呼ぶことにする。

瞳                 ← 単独のグループ
白目 + 眉          ← 同じグループ
鼻 + 口 + 眼鏡     ← 同じグループ

同じグループのパーツは、別々に重ねても1枚にしても結果が変わらない。

それなら、PC側で重ね順どおりに1枚の画像へ合成しておけば、実行時に重ねる回数が減る。

from PIL import Image

def compose(images, names, size):
    base = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    for name in names:                      # 奥 → 手前の順
        base = Image.alpha_composite(base, images[name])
    return base

Image.alpha_composite は、半透明の画像どうしを、透明度を保ったまま重ねられる。

③ パーツがある範囲だけを切り出す

合成した画像は顔全体と同じ大きさで、パーツのない部分は透明である。
そのまま配列にすると、グループごとに顔全体ぶんのメモリを使ってしまう。

そこで、透明でない画素をすべて含む矩形だけを切り出す。
「完全に透明でない」を基準にしているのは、縮小で生じた薄い半透明のにじみを欠けさせないためだ。

def bbox_any(img):
    return img.getchannel("A").getbbox()   # アルファが1以上の画素をすべて含む矩形

④ バイト順を並べ替えて書き出す

def to_argb_bytes(img, rect):
    r, g, b, a = img.crop(rect).split()
    return Image.merge("RGBA", (b, g, r, a)).tobytes()

Pillow の tobytes() は画素を R, G, B, A の順に出す。

ESP32 はリトルエンディアンなので、uint32_t の 0xAARRGGBB として読むには、メモリ上の並びが B, G, R, A になっていなければならない。

先に (b, g, r, a) の順に並べ替えて書き出しておけば、デバイスはファイルの中身をそのまま uint32_t として扱える。

幅と高さはファイルに含めていない。矩形の位置・大きさは別のファイル(定義ファイル)に持たせ、
デバイスはそれを読んで配列の大きさを知る。

2-2. M5Stackで配列を重ねる

起動時に配列ファイルをPSRAMへ読み込む。

uint32_t* buf = static_cast<uint32_t*>(ps_malloc(w * h * 4));
file.read(reinterpret_cast<uint8_t*>(buf), w * h * 4);

実行時は、グループの移動量だけ位置をずらして重ねる。

target.pushAlphaImage(x + move.x, y + move.y, w, h,
                      reinterpret_cast<const m5gfx::argb8888_t*>(buf));

切り出した矩形の原寸なので、位置をずらすだけで平行移動になる。デコードも不要である。

3. 目の周りだけ更新する

視線の動きで変わるのは、目の周辺だけである。画面全体を毎回転送するのは無駄が大きい。
そこで、動くパーツがとりうる範囲を囲む 小さな矩形(バンド) だけを再合成して、その範囲だけを転送する。

バンドの更新は、次の順に行う。

  • 毎回、下地から作り直すので、前のフレームの残像が残らない。
  • 髪など手前にあるパーツを最後に重ね直すことで、本来の重ね順(髪が眉や眼鏡の手前)を保てる。
  • 描画するかどうかは、移動量の小数値ではなく、四捨五入した整数ピクセルが変わったかで判定する。
    1px未満の揺れでは何も転送しない。
  • 目を閉じている間は、見えないので視線の計算だけ進め、描画はしない。

4. 視線の計算

ここまでで、「動くパーツを、指定した量だけずらして重ねる」仕組みはできた。
次に、その「ずらす量」を、視線(追従先の点)からどう決めるかを説明する。
この計算は、描画とは別のクラスに分けてある。

4-1. 点から方向へ(GazeController)

視線の入力は、タッチした位置や、傾きから求めた位置のような、画面上の1点で受け取る。
これを「中立点(正面を見ているときの目の中心)から、どちら向きにどれだけ離れているか」に直す。

void GazeController::lookAt(float x, float y) {
    // 中立点からの変位を、可動域 reach で割って -1..1 にする(範囲外はクランプ)
    targetDirX = clampf((x - neutralX) / reachX, -1.0f, 1.0f);
    targetDirY = clampf((y - neutralY) / reachY, -1.0f, 1.0f);
}
  • -1 は左(上)へ振り切った状態、0 は正面、+1 は右(下)へ振り切った状態である。
  • 画面の外の点を渡しても、±1 に収まる。
  • この値は「方向」だけを表し、何pxずらすかは、まだ決まっていない。

入力(タッチ・傾き)は「点を返すだけ」のモジュールに分けてあり、このクラスは標準ライブラリにしか依存しない。
入力の種類にも描画方法にも依存しないので、他のプロジェクトへそのまま持っていける。

4-2. 方向から移動量へ(GazeOffset)

方向(-1〜1)に、パーツごとの最大移動量を掛けると、ずらす量になる。
さらに、目標へ一気に飛ばさず、毎フレーム少しずつ近づけて滑らかに動かす。

bool GazeOffset::update(float dirX, float dirY) {
    // 目標(方向 × 最大移動量)へ、残りの距離の smooth 割だけ近づける
    curX += (dirX * maxX - curX) * smooth;
    curY += (dirY * maxY - curY) * smooth;

    // 変化の判定は、補間中の実数ではなく、実際に描く整数pxで行う
    const int nx = (int)lroundf(curX);
    const int ny = (int)lroundf(curY);
    if (nx == drawnX && ny == drawnY) return false;   // 1px未満の揺れは再描画しない

    drawnX = nx;
    drawnY = ny;
    return true;
}
  • maxX / maxY: 方向を振り切ったとき(±1)の移動量(px)。
  • smooth: 1フレームで、目標までの残りの距離のどれだけを詰めるか(0〜1)。大きいほど速く追いつく。
  • 戻り値は「描画に使う整数pxが変わったか」で、変わっていなければ再描画しない。

4-3. グループと親子

GazeOffset は、グループごとに1つ持つ。全グループへ同じ方向を渡し、可動量と追従の速さの違いで動きを変える。

// FaceRenderer::begin: グループごとに可動量と追従の速さを設定する
for (size_t i = 0; i < def->groups.size(); ++i) {
    const AvatarGroup& g = def->groups[i];
    groupOffsets[i].begin(g.maxX, g.maxY, g.smooth);
}

// FaceRenderer::updateGaze: 全グループを同じ目標方向へ進める
bool changed = false;
for (GazeOffset& offset : groupOffsets) {
    changed |= offset.update(gaze.dirX(), gaze.dirY());   // 1つでも動けば再描画が要る
}
return changed;
グループ パーツ 最大移動量 追従
瞳 瞳 小〜中 速い
目まわり 白目・眉 中 やや遅い
ディテール 鼻・口・眼鏡 小 遅い

瞳のグループは、「目まわり」のグループを親に持つ。
実際に描く位置は、親をたどって移動量を合算した値になる。

FaceRenderer::Offset FaceRenderer::totalOffset(int group) const {
    Offset total{0, 0};
    for (int g = group; g >= 0; g = def->groups[g].parent) {   // 親がいなければ -1 で終わる
        total.x += groupOffsets[g].x();
        total.y += groupOffsets[g].y();
    }
    return total;
}

白目ごと動いたうえで、その中でさらに瞳が動くので、自然な二重の動きになる。
瞳が速く、顔のパーツが遅れて寄るため、顔全体が傾きへ引っ張られているように見える。

5. 動く範囲の決め方

どこまで動いてよいか、どこを再描画すればよいかは、手で座標を決めず、素材の絵から計測して決めるようにした。
パーツの動く量を変えるたびに、手で座標を直すのは現実的でないためである。
計測はPCのPythonスクリプトで行い、結果をデバイスが読み込んで使う。

5-1. 瞳のはみ出し防止

瞳が動いて白目からはみ出すと、不自然になる。
そこで、白目の画像のある程度以上不透明な部分(アルファがしきい値以上、既定は128)を囲む矩形を求めて、
瞳をその範囲に切り取って(クリップして)描く。

注意点が1つあった。左右の白目は、鼻筋で分かれている。
外接矩形を1つだけ取ると、鼻筋の側の隙間へ、瞳がはみ出してしまう。
そこで、不透明な画素が1つも無い列で区切って、左右それぞれの矩形を取る。

def component_rects(img, threshold):
    """不透明画素が1つも無い列で区切り、区切られた範囲ごとの矩形を左から順に返す"""
    mask = opaque_mask(img, threshold)
    width, height = mask.size
    rects, start = [], None
    for x in range(width + 1):
        filled = x < width and mask.crop((x, 0, x + 1, height)).getbbox() is not None
        if filled and start is None:
            start = x                              # 不透明な列が始まった
        elif not filled and start is not None:
            box = mask.crop((start, 0, x, height)).getbbox()
            rects.append((start, box[1], x, box[3]))   # 列が途切れた → 矩形を確定
            start = None
    return rects
 ┌──────┐   ┌──────┐
 │ 左の │   │ 右の │   ← 左右別々の矩形でクリップする
 │ 白目 │鼻筋│ 白目 │
 └──────┘   └──────┘

デバイス側では、矩形ごとに setClipRect で範囲を絞って、瞳を重ねる。

// 親(白目)が動いたぶんだけ、クリップ矩形も一緒にずらす
const Offset parentMove = totalOffset(group.parent);
for (const AvatarRect& clip : group.clipRects) {            // 左右の白目で2回
    target.setClipRect(clip.x + parentMove.x - def->band.x,
                       clip.y + parentMove.y - def->band.y,
                       clip.w, clip.h);
    target.pushAlphaImage(x, y, w, h, argb);
}
target.clearClipRect();

白目が動けば、瞳を切り取る範囲も白目に追従する。
矩形どうしは重ならない前提である(重なると、同じ画素を二重に合成してしまう)。

5-2. 更新する範囲の決め方

視線の変化で再合成・転送するバンドも、計測して決める。

def total_max(cfg, group):
    # 親をたどって、最大移動量を合算する(デバイスの totalOffset と同じ考え方)
    mx = my = 0
    while group is not None:
        g = cfg["groups"][group]
        mx += g["max"][0]
        my += g["max"][1]
        group = g["parent"]
    return mx, my

def compute_band(runs, totals, size, face_origin):
    band = None
    for run in runs:
        if run["group"] != FIXED and run["rect"]:
            mx, my = totals[run["group"]]
            band = rect_union(band, rect_expand(run["rect"], mx, my))   # 可動量ぶん広げて、全部を囲む
    # 画面に映る範囲に制限する(画面外を合成・転送しても無駄なため)
    return rect_intersect(rect_intersect(band, (0, 0, size, size)), visible)

手順をまとめると次の通りになる。

  1. 動く各グループの、パーツがある矩形を測る(縮小後の画像で測る)。
  2. その矩形を、グループの最大移動量(親がいれば親の分も合算)だけ、上下左右に広げる。
  3. 広げた矩形をすべて囲み、画面に映る範囲に制限したものを、バンドとする。

縮小後の画像で測るので、計測した座標と、デバイスが描く画素が一致する。

最初は、全グループに一律の余白を取っていた。グループごと・方向ごとの余白にすると、バンドが小さくなり、転送量も減った。

デバイスは、計測されたバンドの大きさのスプライトを確保し、画面上の位置へ転送する。

// 起動時: バンドと同じ大きさの作業用スプライトを、PSRAMに確保する
bandCanvas.createSprite(def->band.w, def->band.h);

// 画面上の位置は、顔の原点にバンドの位置を足したもの
bandScreenX = def->faceOriginX + def->band.x;
bandScreenY = def->faceOriginY + def->band.y;

// 実行時: パーツの位置は、バンドの左上を原点とした座標へ直して重ねる
int x = stage.rect.x - def->band.x;
int y = stage.rect.y - def->band.y;

まとめ

  • drawPng は、描くたびにデコードが走り、スプライト宛だと透明度も保持しない。毎フレーム重ねるのには向かない。
  • 重い処理は起動時(とPC側)に済ませ、実行時は、出来上がった絵を動かすだけにする。
  • 動くパーツは、PCで正しく合成してARGB値の配列にし、pushAlphaImage で重ねる。
  • 視線は「追従先の点 → 方向(-1〜1) → グループごとの移動量(px)」の3段階で決める。計算は描画から分離してある。
  • 視線が動いたときは、画面全体ではなく目の周辺のバンドだけを更新する。
  • 動く範囲(クリップ矩形・バンド)は、手で決めず素材から計測する。左右の白目は別々の矩形で扱う。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?