【遊び】2歳の息子に「さわると追いかけてくる車」を作った

その他

今回はWMXの初学者さんのために、速度プロファイルについて、感覚的にこんなもん!をお伝えします。 

アプリについて

画面をさわると、そこへ車が走ってきます。急ブレーキはかけられないので、曲がるときは少しふくらみます。それだけのアプリを、2歳の息子のために作りました。

この記事にそのまま埋め込んであります。スマホでもタブレットでも動くので、まず指でさわってみてください。押したままなぞると、ずっとついてきます。

うまく動かないときは 別のタブで開く

「急には止まれない」が、車らしさの正体だった

最初は、指の位置に車をそのまま置いていました。動きはします。でも、まったく面白くありません。指にぴったり張り付いた四角は、車ではなくカーソルだからです。

面白くなったのは、目標に「向かって走る」ように変えてからでした。指をパッと遠くへ動かすと、車はまず加速し、最高速度で走り、目標が近づくと減速して、ぴたりと止まります。走っている途中で指を真横へ動かすと、すぐには曲がれずに大きくふくらみます。

この「すぐには言うことを聞かない」感じが、そのまま車らしさになりました。息子が笑ったのもここです。

3つの数字が、車の性格を決めている

中で効いているのは、たった3つの値です。

  • 最高速度 — どこまで速く走れるか
  • 最大加速度 — どれくらいの勢いで速度を変えられるか。小さいほど「急には止まれない」
  • 最大躍度(ジャーク) — 加速度自体をどれくらいの速さで変えられるか。小さいほど、走り出しがぬるっとする

ジャーク(Jerk)は日本語で「躍度(やくど)」、または「加加速度(かかそくど)」と呼ばれます。物理学や制御工学において、「加速度が時間とともにどれくらい変化するか(加速度の時間変化率)」を表す指標です。

3つ目の躍度は聞き慣れないかもしれません。加速度の変化率のことです。これを制限しないと、動き出しと止まりぎわがカクンとして、乗り物というより瞬間移動に見えます。

手元で測ると、1000ピクセルの移動にかかった時間は1.87秒でした。ここで加速度だけを1/4にすると、同じ距離が3.31秒になります。コードは1行も変えていません。数字を1つ変えただけで、きびきびした車が、重いトラックのような性格に変わります。

上のアプリの右下にある「車の性格を変える」から、この3つを走らせたまま動かせます。「もっさり」を押した瞬間に、同じ車が別の乗り物になるのが、指で確かめられるはずです。

これは、工場のロボット制御と同じ考え方です

ここまで読んで「ゲームの話でしょう」と思われたかもしれません。ところが、この3つの値は産業用のモーションコントローラでもそのまま同じ名前で出てきます。速度・加速度・躍度を指定して、目標位置を渡す。返ってくるのは現在位置。画面の中の車も、工場で基板に部品を置くロボットも、動かすときに考えていることは同じです。

違うのは精度と責任の重さだけで、考え方は地続きでした。私が「制御って楽しいよ」と息子に言いたくなったのは、この地続き感が面白かったからです。

ひとつ正直に書いておくと、この記事に埋め込んだアプリは、産業用のモーションエンジンを使っていません。同じ考え方をブラウザ上で自分で書いたものです。加減速も躍度制限も手書きです。

では本物のモーションエンジンで動かすとどうなるのか。それがこの先やろうとしていることで、使うのは Movensys の WMX3 です。無償の体験版があり、実機のモーターが無くても仮想軸を動かせるので、上のスライダーと同じことをソフトウェアのモーションエンジンで試せます。

WMX3 体験版をダウンロードする(Movensys 公式サイト)

まとめ

  • 指に張り付くと面白くない。目標へ向かって加減速するようにした瞬間に、四角が車になった
  • 性格を決めているのは速度・加速度・躍度の3つだけ。加速度を1/4にすると、1.87秒だった到達が3.31秒になる
  • この3つは産業用モーションコントローラでも同じ。画面の中の車と工場のロボットは地続き


アプリ制作の裏話(作るときに効いた判断)

位置制御を、細い境界の裏に隔離する

最初はWPFアプリで作り、その後Web版を作成しました。

アプリと位置制御のあいだに、契約が2つしかない境界を1枚はさみました。目標位置を渡すと、現在位置が返る。それだけです。

こうしておくと、中身を自前の計算から本物のモーションエンジンに差し替えても、呼び出す側は1行も変わりません。差し替えの影響が境界の裏側だけで閉じます。最初にこれを決めたおかげで、WindowsアプリからWeb版への移植も、書き直したのは画面まわりだけで済みました。

この形は、ソフトウェア設計では依存性逆転の原則(Dependency Inversion Principle、略してDIP)と呼ばれます。アプリがWMX3に依存するのではなく、アプリもWMX3も「目標を渡す/現在位置が返る」という約束のほうに依存する、という向きの逆転です。

制御や組込みの世界では、同じものをハードウェア抽象化層(Hardware Abstraction Layer、HAL)と呼びます。装置ごとの事情を1枚の層に閉じ込めて、上の階には見せない。呼び名は違いますが、やっていることは同じです。

実測しないと分からなかった3つの罠

加減速の計算は、式を書けば動くだろうと思っていました。実際には、次の3つを実測で潰すまでまともに止まりませんでした。

  • 減速の判断に現在速度を混ぜてはいけない。「速く走る→止まるのに必要な距離が縮む→減速する→遅くなる→必要な距離が伸びる→また加速する」を無限に繰り返し、目標の周りを往復し続けます。残り距離だけで決めると素直に止まります
  • 「必要な加速度=速度の差÷時間」にしてはいけない。制御周期が1ミリ秒だと指令がほぼ常に上限に張り付き、実質オンオフ制御になります。目標付近で数ピクセルの振動が消えませんでした
  • ブラウザでは制御周期を固定しないといけない。画面の更新間隔をそのまま計算に使うと、60Hzの画面と120Hzのタブレットで車の性格が変わります。時間を貯めて1ミリ秒ずつ進めるようにして揃えました

どれも、動かして目で見るまで気づけませんでした。制御が面白いのは、たぶんこの「机上で正しい式が、実機で正しいとは限らない」ところです。

あとがき

子供のパワーは凄まじい。この素晴らしい日常を、コミカルにWMXとC#コードで記録する。

using WMX息子;
using WMX息子.Constants;

/// <summary>
/// 2歳児の1日。位置制御の言葉で書くと、だいたいこうなる。
/// </summary>
class 息子の1日
{
    static void Main()
    {
        WMX息子Api wmx息子 = new WMX息子Api();
        CoreMotion wmx息子_cm = new CoreMotion(wmx息子);

        // デバイス生成。実測で約10ヶ月かかる。気長に待とう。
        ErrorCode ret = wmx息子.CreateDevice(@"C:\Program Files\我が家",
                                             DeviceType.DeviceTypeNormal);
        if (ret != ErrorCode.None)
        {
            Console.WriteLine($"生成失敗: {WMX息子Api.ErrorToString(ret)}");
            return;
        }

        // 通信開始。みるみる意思疎通ができるようになる
        wmx息子.StartCommunication();

        while (true)
        {
            ret = 一日を回す(wmx息子_cm);
            Console.WriteLine($"本日: {WMX息子Api.ErrorToString(ret)}");
        }
        // wmx息子.CloseDevice() には到達しない。

    }

    /// <summary>1日ぶん回す。戻り値は「今日がどう終わったか」。</summary>
    static ErrorCode 一日を回す(CoreMotion cm)
    {
        // ── 原点復帰(起きる) ──────────────────────────────
        // 07:00。こちらが呼ばなくても勝手に始まる。中断する方法は無い。
        // 原点センサは母。父を原点として登録する手順は現在も調査中。
        ErrorCode ret = cm.motion.StartHome(Axis.息子);
        if (ret == ErrorCode.原点センサが寝ている)
        {
            // 起きるまで待つ
            return ret;
        }

        // サーボON。指令していないのに、起きた時点で入っている。
        cm.motion.SetServoOn(Axis.息子, true);
        cm.motion.SetServoOn(Axis.息子, false);   // ← 定義はあるが効かない

        // ── 稼働(遊び) ────────────────────────────────────
        // ここで StartMov(目標位置へ動いて止まる)を使ってはいけない。
        // 2歳の遊びに目標位置は存在しない。あるのは「動き続けたい」だけ。
        // 方向と速度だけ与えて走らせっぱなしにする StartJog が正しい。
        cm.motion.StartJog(Axis.息子, Direction.どこへでも);
        // StartJog はこの行で即座に返る。まだ何も終わっていない。


        // 実際に動いているのは向こうのサイクルで、こちらは別スレッドで見張るしかない。
        // これを「非同期」と呼ぶと聞こえはいいが、要するに目を離せないということである。
        while (cm.status.IsRunning(Axis.息子))
        {
            // 一緒に遊ぶ。
            // このループを抜けられるのは向こうが止まったときだけで、
            // こちらの体力は終了条件に入っていない。
        }

        // ── ドック(布団)へ戻る ────────────────────────────
        // 20:30。減速に入った瞬間に目標位置の変更が飛んでくる(「もういっかい」)。
        // 加速度は有限なので、指令を出してから止まるまでには必ず距離が要る。
        // 「はい終わり」で止まらないのは、わがままではなく物理である。
        do
        {
            ret = cm.motion.StartMov(Axis.息子, Target.布団);
        }
        while (ret == ErrorCode.走行中に目標が変わった);   // リトライ上限は設けない。設けても無駄

        // ── 自動Sleep(エネルギーチャージ) ─────────────────
        // 完了待ち。タイムアウトは長めに取る。短くしても向こうは合わせてくれない。
        cm.motion.Wait(Axis.息子, WaitCondition.寝た, timeoutMs: 90 * 60 * 1000);

        // 停止中も電源は落ちない。むしろ充電されている。
        // 翌朝の原点復帰では、前日より確実に出力が上がっている。
        // おまけに毎晩ファームウェアが勝手に更新され、
        // 前日には無かった機能が実装されている。
        return ErrorCode.None;
    }
}

次の遊びは、この車を本物のモーションエンジンで動かす予定。2歳の指が、仮想軸を通して動く車を追いかけることになるのですが、できるかな?(笑) コーディング的にも。

コメント

タイトルとURLをコピーしました