【M5Stack Cardputer】加速度センサでボール物理シミュレーションを作る
M5Stack Cardputer ADVに載っている6軸IMU(BMI270)を使い、本体を傾けるとボールが転がる物理シミュレーションを作ります。この記事では、PlatformIOでの書き込みから、重力・摩擦・反発・ボール同士の衝突という物理演算の実装まで、実際に動かして確認した内容をもとに整理しました。
はじめに
手のひらサイズのマイコンで、画面の中の物体が現実のように動くと見ていて楽しいものです。M5Stack Cardputerはディスプレイとキーボードに加えて加速度センサ(IMU)を内蔵しているので、本体の傾きをそのまま画面に反映するアプリが作れます。
この記事では、私が実際に作って動かしたボール物理シミュレーションを題材に、IMUの値の読み方と、ボールが転がるための最低限の物理演算を解説します。物理エンジンのライブラリは使わず、自前の計算だけで組み立てます。
作ったもの
30個の色とりどりのボールが画面の中に詰められていて、本体を傾けると傾いた方向へ転がります。ボールは画面の壁で跳ね返り、ボール同士でも衝突します。画面の左右には、IMUから取得した加速度とジャイロの値がリアルタイムで表示されます。
使っているハードウェアの主な仕様は次のとおりです。
| パーツ | 仕様 |
|---|---|
| MCU | ESP32-S3FN8(Xtensa LX7 デュアルコア / 8MB flash) |
| ディスプレイ | ST7789V2 1.14インチ 240×135 |
| IMU | BMI270(6軸) |
| キーボード | 56キー |
| オーディオ | ES8311コーデック + スピーカー |
実際に書き込んで動かしてみたところ、傾けた向きに合わせてボールが転がるのを確認できました。ボール30個程度までは動作が重くなる感じはなく、滑らかに動いています。それ以上に増やすと重くなる可能性はありますが、試した範囲(〜30個)では問題ありませんでした。

ボール同士の衝突判定は、すべてのボールの組み合わせを総当たりで調べる方式です。ボールの個数を N とすると1フレームあたり N^2 回の判定になるため、個数を増やすほど計算量が増えます。30個なら900回で、ESP32-S3には十分余裕があります。
なお、ソースコード全体はここにあります。
環境構築と書き込み
ビルドにはPlatformIOを使います。platformio.ini は次のとおりです。
[env:m5stack-stamps3]
platform = espressif32
board = m5stack-stamps3
framework = arduino
lib_deps =
m5stack/M5Unified@^0.2.19
m5stack/M5Cardputer@^1.1.1
m5stack/M5GFX@^0.2.26書き込みコマンドは次の2つです。pio run でビルド、pio run -t upload で本体へ転送します。
pio run # ビルド
pio run -t upload # 書き込みCardputerの書き込みでは、通常の状態ではデバイスが認識されません。ダウンロードモードにしてからUSB接続します。手順は次のとおりです。
- 側面の電源スイッチをOFFにする
- G0ボタンを押しながらUSBケーブルを接続し、電源をONにする
- G0ボタンを離す
実装のポイント
プログラムは main.cpp になります 。以下、要点ごとに抜粋しながら説明します。
IMUの値を読む
IMUは M5.Imu から取得します。毎フレーム update() を呼んでから getImuData() で値を読みます。
M5.Imu.update();
auto imu = M5.Imu.getImuData();
// imu.accel.x / y / z … 加速度(g単位、静止時は約1.0)
// imu.gyro.x / y / z … 角速度加速度は g単位(重力加速度を1とした値)で返ってきます。静止していれば、どの軸かに約1.0が乗るイメージです。この値をそのまま「傾き」として使います。
傾きを重力に変換する
加速度から、ボールにかかる重力(加速度)を作ります。
constexpr float SCALE = 800.0f; // 傾き(g単位) → 加速度[px/s²] の変換係数
const float gx = -imu.accel.x * SCALE;
const float gy = imu.accel.y * SCALE;SCALE は「傾き1gを、画面の加速度いくつ分とみなすか」を決める係数です。値を大きくすると、少し傾けただけで勢いよく転がります。X軸だけ符号を反転しているのは、実機の座標系と画面の座標系で向きが逆になっているためです。ここは使う機体や向きによって変わるので、実際に動かして調整するのが確実です。
速度と位置を更新する
物理の更新は、速度に加速度を足し、その速度で位置を動かす、というシンプルな積分です。
a.vx += gx * dt; // 重力で速度が増える
a.vy += gy * dt;
a.vx *= DAMP; // 摩擦で少しずつ減速
a.vy *= DAMP;
a.x += a.vx * dt; // 速度で位置が動く
a.y += a.vy * dt;dt は前フレームからの経過時間(秒)です。millis() の差分から実測するので、フレームレートが多少落ちても動きの速さが変わらないようにしています。DAMP は0〜1の減衰係数で、1に近いほどよく滑ります。
ループ側では、dt に上限を設けています。
float dt = (now - prev) / 1000.0f;
if (dt > 0.05f)
{
dt = 0.05f; // 長時間の停止で急加速するのを防ぐ
}一時的に処理が止まって dt が大きくなると、その分だけ速度が一気に増えてボールが吹き飛んでしまいます。上限を入れておくと挙動が安定します。
壁で跳ね返す
画面の端に達したら、位置を戻して速度の向きを反転します。反転時に RESTIT(反発係数)を掛けて、少しずつ勢いを減らします。
if (balls[i].x < r)
{
balls[i].x = r; // はみ出しを戻す
balls[i].vx = fabsf(balls[i].vx) * RESTIT; // 内向きに反転 + 減衰
}fabsf を使って「必ず内向きになるように」符号を決めているのがポイントです。
ボール同士の衝突
ここが物理演算で少しだけ面倒な部分です。ボールの中心間距離が半径の和より小さいとき、重なっているぶんを押し戻し、速度を交換します。
float dx = b.x - a.x;
float dy = b.y - a.y;
float dist2 = dx * dx + dy * dy;
float minDist = 2.0f * r;
if (dist2 < minDist * minDist && dist2 > 1e-6f)
{
float dist = sqrtf(dist2);
float nx = dx / dist; // a→b 方向の単位ベクトル
float ny = dy / dist;
float overlap = minDist - dist;
a.x -= nx * overlap; // 重なりをaだけ押し戻す
a.y -= ny * overlap;
float rvn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; // 法線方向の相対速度
if (rvn < 0) // 近づいているときだけ衝突
{
float imp = (1.0f + RESTIT) * rvn * 0.5f;
a.vx += nx * imp;
a.vy += ny * imp;
b.vx -= nx * imp;
b.vy -= ny * imp;
}
}この方式は簡易的な方法で、ボールを1個ずつ順番に処理し、そのつど衝突を解決していきます。厳密な物理シミュレーションではありませんが、見た目には十分自然に動きます。rvn < 0(=近づいている)のときだけ衝突処理をするのが重要で、離れていく最中のボールを引き戻さないようにしています。
画面に描く
描画は M5Canvas にいったん描いて、最後にまとめて画面へ転送します。これをダブルバッファと呼び、描画途中の状態が見えてちらつくのを防ぎます。
M5Canvas sprite(&M5.Display); // 画面全体のバッファ
// ... 背景 → ボール → 情報パネル の順に描く ...
sprite.fillScreen(BLACK);
for (int i = 0; i < NUM_BALLS; i++)
{
sprite.fillCircle(int(balls[i].x), int(balls[i].y), r, balls[i].color);
}
// ... 加速度・ジャイロのパネルを描く ...
sprite.pushSprite(0, 0); // まとめて画面に転送画面は setRotation(1) で横向き(240×135)にしています。座標が画面サイズに依存しないよう、max_w / max_h を setup() で取得して使っています。
パラメータの調整
動きの印象は、main.cpp の先頭にある定数で変えられます。
| 定数 | 意味 | 変えるとどうなるか |
|---|---|---|
NUM_BALLS | ボールの個数 | 増やすほど賑やかだが重くなる(計算量は約2乗) |
r | ボールの半径[px] | 大きくすると1個1個が目立つ |
wait | フレーム間隔[ms] | 小さくすると滑らか、大きくするとカクカク |
SCALE | 傾き→加速度の変換係数 | 大きくすると勢いよく転がる |
DAMP | 摩擦の減衰 | 1に近いほどよく滑る |
RESTIT | 反発係数 | 大きくするとよく跳ねる |
まずは SCALE と DAMP を変えて、動きの好みに合わせるのが調整しやすいと思います。
つまづいた点
- IMUの符号が思った向きと違う
取り付けた向きによって軸の符号が変わります。傾けてみて逆に転がるなら、gx/gyのプラスマイナスを入れ替えます - フレーム落ちでボールが飛んだ
dtが大きくなると1回の更新で速度が増えすぎます。前述のdt上限で抑えられます - ボールを増やすと重くなる
衝突判定が総当たり(約 N^2)のため、個数に比例して重くなります。30個あたりが一つの目安です - 画面がちらついた
直接描画するとちらつきます。M5Canvasに描いてからpushSpriteで転送すると解消します

ボール同士の衝突は「押し戻す相手が既に処理済みかどうか」を厳密には区別していません。そのため、ぎゅうぎゅう詰めるとごくまれに重なりが残ることがあります。見た目を優先した割り切りです。
全ソースコード
ここまでの内容を1つにまとめた main.cpp の全体です。このファイル1枚で動きます。
#include <M5Cardputer.h>
#include <math.h>
M5Canvas sprite(&M5.Display); // 画面全体のダブルバッファ (描画はスプライトに描いて pushSprite で転送)
int max_h, max_w; // ディスプレイサイズ (setup で決定)
// ---- 物理パラメータ (ここで調整) ----
constexpr int NUM_BALLS = 30; // ボールの個数
constexpr int r = 5; // ボール半径 [px]
constexpr uint32_t wait = 16; // フレーム間隔 [ms]
constexpr float SCALE = 800.0f; // 傾き(重力, g 単位) → 加速度 [px/s²] の変換係数
constexpr float DAMP = 0.99f; // 毎フレームの摩擦減衰 (1 に近いほど滑る)
constexpr float RESTIT = 0.85f; // 反発係数 (壁・ボール同士の衝突に共用)
constexpr int spacing = 2 * r + 6; // 初期配置の間隔 [px]
// ボールの配色パレット (個数が 8 を超えても剰余で循環)
const uint16_t palette[] = {YELLOW, CYAN, MAGENTA, GREEN, ORANGE, RED, BLUE, WHITE};
constexpr int PALETTE_SIZE = sizeof(palette) / sizeof(palette[0]);
// ボール 1 個分の状態 (位置/速度は px と px/s、色は初期化時に自動割り当て)
struct Ball
{
float x = 0, y = 0;
float vx = 0, vy = 0;
uint16_t color;
Ball()
{
static int idx = 0; // 配列の宣言順に 0,1,2,... と色を割り当てる
color = palette[idx++ % PALETTE_SIZE];
}
};
Ball balls[NUM_BALLS];
// 1 フレーム分の物理更新と描画。dt は実測の経過時間 [s] で、フレームレート非依存
void drawValues(float dt)
{
M5.Imu.update();
auto imu = M5.Imu.getImuData();
// 傾きによる重力 (accel は g 単位: 静止時 1g ≈ 9.8 m/s²)。
// 符号は実機の向きで反転が必要なら ± を入れ替える
const float gx = -imu.accel.x * SCALE;
const float gy = imu.accel.y * SCALE;
// --- ① 運動積分 + ボール同士の衝突 (逐次インパルス): ボールごとに積分→接触解決 ---
for (int i = 0; i < NUM_BALLS; i++)
{
Ball &a = balls[i];
a.vx += gx * dt; // 重力から速度を増加
a.vy += gy * dt;
a.vx *= DAMP; // 摩擦で速度を減衰
a.vy *= DAMP;
a.x += a.vx * dt; // 速度から位置を更新
a.y += a.vy * dt;
// 接触中の全ボールとの衝突解決 (位置補正は今動かした a のみ、速度は対称に交換)
for (int j = 0; j < NUM_BALLS; j++)
{
if (j == i) continue;
Ball &b = balls[j];
float dx = b.x - a.x;
float dy = b.y - a.y;
float dist2 = dx * dx + dy * dy;
float minDist = 2.0f * r; // 半径の和
if (dist2 < minDist * minDist && dist2 > 1e-6f) // 接触(かつ同一点を除外)
{
float dist = sqrtf(dist2);
float nx = dx / dist; // a→b 方向の単位法線
float ny = dy / dist;
// 重なりを a だけ押し戻す (b は既に確定済み。b 側は b の番で自身が補正される)
float overlap = minDist - dist;
a.x -= nx * overlap;
a.y -= ny * overlap;
// 法線方向の相対速度。接近中 (rvn < 0) のときだけ衝突処理
float rvn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (rvn < 0)
{
// 等質量なので、反発係数を反映した速度交換を半分ずつ分け合う
float imp = (1.0f + RESTIT) * rvn * 0.5f;
a.vx += nx * imp;
a.vy += ny * imp;
b.vx -= nx * imp;
b.vy -= ny * imp;
}
}
}
}
// --- ② 壁との衝突 (反発係数 RESTIT で反射) ---
for (int i = 0; i < NUM_BALLS; i++)
{
if (balls[i].x < r)
{
balls[i].x = r; // はみ出しを補正
balls[i].vx = fabsf(balls[i].vx) * RESTIT; // 内向きへ反転+減衰
}
else if (balls[i].x > max_w - r)
{
balls[i].x = max_w - r;
balls[i].vx = -fabsf(balls[i].vx) * RESTIT;
}
if (balls[i].y < r)
{
balls[i].y = r;
balls[i].vy = fabsf(balls[i].vy) * RESTIT;
}
else if (balls[i].y > max_h - r)
{
balls[i].y = max_h - r;
balls[i].vy = -fabsf(balls[i].vy) * RESTIT;
}
}
// --- ③ 描画: 背景 → 全ボール (背面) → 情報パネル (前面・重なり可) ---
sprite.fillScreen(BLACK);
for (int i = 0; i < NUM_BALLS; i++)
{
sprite.fillCircle(int(balls[i].x), int(balls[i].y), r, balls[i].color);
}
sprite.setTextColor(WHITE);
// 左パネル: 加速度
sprite.drawRect(4, 2, 116, 78, WHITE);
sprite.setCursor(8, 6);
sprite.print("ACCEL");
sprite.setCursor(8, 24);
sprite.printf("X %+6.2f", imu.accel.x);
sprite.setCursor(8, 42);
sprite.printf("Y %+6.2f", imu.accel.y);
sprite.setCursor(8, 60);
sprite.printf("Z %+6.2f", imu.accel.z);
// 右パネル: ジャイロ
sprite.drawRect(124, 2, 112, 78, WHITE);
sprite.setCursor(128, 6);
sprite.print("GYRO");
sprite.setCursor(128, 24);
sprite.printf("X %+6.2f", imu.gyro.x);
sprite.setCursor(128, 42);
sprite.printf("Y %+6.2f", imu.gyro.y);
sprite.setCursor(128, 60);
sprite.printf("Z %+6.2f", imu.gyro.z);
sprite.pushSprite(0, 0); // スプライトを実画面へ転送
}
void setup()
{
auto cfg = M5.config();
M5Cardputer.begin(cfg, true);
M5.Display.setRotation(1); // 横長 (240x135)
M5.Display.fillScreen(BLACK);
max_h = M5.Display.height();
max_w = M5.Display.width();
sprite.createSprite(max_w, max_h);
sprite.setColorDepth(16);
sprite.setTextSize(2);
// --- 初期位置: 右下を起点にグリッド敷き詰め (個数に自動追従) ---
int cols = 1;
while (cols * cols < NUM_BALLS)
{
cols++; // 縦横均等に収まる列数 = ceil(√N)
}
for (int i = 0; i < NUM_BALLS; i++)
{
int row = i / cols; // 下からの行
int col = i % cols; // 右からの列
balls[i].x = max_w - r - col * spacing;
balls[i].y = max_h - r - row * spacing;
balls[i].vx = 0;
balls[i].vy = 0;
}
}
uint32_t prev = 0; // 前回フレームの時刻
void loop()
{
uint32_t now = millis();
if (now - prev > wait) // フレーム間隔を満たしたら更新
{
float dt = (now - prev) / 1000.0f; // 実測 dt で物理がフレーム落ちに追従
if (dt > 0.05f)
{
dt = 0.05f; // 長時間の停止等での急加速スパイクを抑制
}
drawValues(dt);
prev = now;
}
delay(1);
}まとめ
M5Stack CardputerはIMUとカラー液晶を内蔵しているので、傾きに反応するアプリを1つのファイルで作れます。今回のポイントは、IMUの加速度をそのまま重力として使い、速度と位置を積分し、壁とボール同士の衝突を簡易的に解くという3点です。物理エンジンを使わなくても、これだけの式で自然に転がるものが作れます。実際に動かすと、傾け方や SCALE の値で挙動が変わるのが面白いので、まずはパラメータを変えながら手に馴染む動きを探してみてください。

