はじめに
![]() |
M5Stack Core2 に、フルカラーのキャラクターの顔を表示し、まばたきと視線(傾けた方向・タッチした方向を見る)を付けた。
この記事では、それをどう動くところまで持っていったかをまとめる。
実行環境
- ボード: M5Stack Core2(PlatformIO)
- ライブラリ: M5Unified / M5GFX
- キャラクター作成: Rive(好みで選んだだけで、パーツごとのPNGが書き出せればよい)
- 素材の変換: Python(Pillow)
ソースコード
キャラクターアセット
顔は、パーツごとに分けた透過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)
手順をまとめると次の通りになる。
- 動く各グループの、パーツがある矩形を測る(縮小後の画像で測る)。
- その矩形を、グループの最大移動量(親がいれば親の分も合算)だけ、上下左右に広げる。
- 広げた矩形をすべて囲み、画面に映る範囲に制限したものを、バンドとする。
縮小後の画像で測るので、計測した座標と、デバイスが描く画素が一致する。
最初は、全グループに一律の余白を取っていた。グループごと・方向ごとの余白にすると、バンドが小さくなり、転送量も減った。
デバイスは、計測されたバンドの大きさのスプライトを確保し、画面上の位置へ転送する。
// 起動時: バンドと同じ大きさの作業用スプライトを、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段階で決める。計算は描画から分離してある。
- 視線が動いたときは、画面全体ではなく目の周辺のバンドだけを更新する。
- 動く範囲(クリップ矩形・バンド)は、手で決めず素材から計測する。左右の白目は別々の矩形で扱う。

