🔥

四元数からMadgwickフィルタまで

に公開
2026/01/03

SO(3)とSU(2)と単位四元数

3次元回転群の扱いに(単位)四元数が使える、というのは概ね次の事実の組み合わせによります。

  1. SO(3)の二重(普遍)被覆としてSU(2)がとれる。
  2. ↑の準同型をSU(2)の随伴作用から作れる。
  3. SU(2)とそのリー代数はパウリ行列で展開できる。
  4. パウリ行列(のi倍)は四元数基底と全く同じ関係式を満たす。

これより、

  1. 回転群自由度の保持と更新を、SO(3)で行うのではなく、SU(2)で行う。
  2. これを具体的な3次元ベクトルに作用したいときは、随伴作用で処理する。
  3. SU(2)の保持やそのリー代数の計算も、パウリ行列による展開成分で行う。
  4. それは(単位)四元数を保持して計算することで実現できる。

ということになります。

具体的に見ましょう。SU(2)のリー代数基底としてパウリ行列による\frac{-i}{2}\sigma_iを取ることができます。パウリ行列は

\sigma_i\sigma_j = \delta_{ij}+i\epsilon_{ijk}\sigma_k

を満たすので、とくに

\left[\frac{-i}{2}\sigma_i, \frac{-i}{2}\sigma_j \right] = \epsilon_{ijk}\frac{-i}{2}\sigma_k

であり、これはSO(3)リー代数基底

E_1 = \begin{bmatrix} 0&0&0\\ 0&0&-1\\ 0&1&0 \end{bmatrix} E_2 = \begin{bmatrix} 0&0&1\\ 0&0&0\\ -1&0&0 \end{bmatrix} E_3 = \begin{bmatrix} 0&-1&0\\ 1&0&0\\ 0&0&0 \end{bmatrix}

と同じ交換関係をもちます。SU(2)は単連結であることが知られており、単連結コンパクトなリー群は、リー代数の指数写像一つで任意の要素を表せます。言い換えると、SU(2)は、そのリー代数の指数関数のような形をしているとして一般性を失いません。実際に指数関数を計算すると、パウリ行列の関係から

\begin{align*} &\exp\left(\frac{-i}{2}\mathbf{v}\cdot \mathbf{\sigma}\right) = & \cos\left(\frac{|\mathbf{v}|}{2}\right) - i\frac{\mathbf{v}}{|\mathbf{v}|}\cdot \mathbf{\sigma} \sin\left(\frac{|\mathbf{v}|}{2}\right) \end{align*}

となります。SU(2)のもとは常にこの形を取っているとみなせます。それは1,-i\sigma_i,-i\sigma_2,-i\sigma_3の線形結合になっており、その係数の二乗和は1です。逆に、この4要素の線形結合で、成分の二乗和が1であるものはこの形にできます。1,-i\sigma_i,-i\sigma_2,-i\sigma_3は、四元数の基底1,\mathbf{i},\mathbf{j},\mathbf{k}と全く同じ演算規則をもつことから、SU(2)は、単位四元数と同型だとわかります。SU(2)と単位四元数の間の変換は、SU(2)を上記の形に書いて、-i\sigma_1,-i\sigma_2,-i\sigma_3を\mathbf{i},\mathbf{j},\mathbf{k}に置き換えればよいことになります。

SU(2)からSO(3)の準同型を次の随伴作用で与えます。U \in SU(2)に対して、任意の\mathbf{w} \in \mathbb{R}^3に対して、

U(\mathbf{w}\cdot \mathbf{\sigma}) U^\dagger = (R\mathbf{w})\cdot \mathbf{\sigma}

となるような、R \in SO(3)をとることができます。このU \mapsto RがSU(2)\rightarrow SO(3)の準同型です。

この準同型は、実際先のリー代数の準同型と整合的です。例えばU = 1 + \epsilon \frac{-i}{2}\mathbf{v}\cdot \mathbf{\sigma}だとしてこれを計算すると、\epsilonの1次で

\left(1 + \epsilon \frac{-i}{2}\mathbf{v}\cdot \mathbf{\sigma}\right)(\mathbf{w}\cdot \mathbf{\sigma}) \left(1 - \epsilon \frac{-i}{2}\mathbf{v}\cdot \mathbf{\sigma}\right) = \mathbf{w}\cdot \mathbf{\sigma} + \epsilon (\mathbf{v}\times \mathbf{w}) \cdot \mathbf{\sigma}

ここで(\mathbf{v}\times \mathbf{w})^i =\epsilon_{ijk} v^j w^kですが、SO(3)のリー代数基底は(E_i)_{jk} = -\epsilon_{ijk}なので、(\mathbf{v}\times \mathbf{w})_i = \epsilon_{ijk} v^j w^k = -\epsilon_{jik} v^j w^k = (v^jE_j)_{ik}w^k

したがって、この準同型の微分写像は、それぞれのリー代数を

\frac{-i}{2}\sigma_\ast \mapsto E_\ast

で写像していることがわかります。

ここまででわかったことは、まずU \in SU(2)と四元数が

U = \cos\left(\frac{|\mathbf{v}|}{2}\right) - i\frac{\mathbf{v}}{|\mathbf{v}|}\cdot \mathbf{\sigma} \sin\left(\frac{|\mathbf{v}|}{2}\right) \Leftrightarrow \cos\left(\frac{|\mathbf{v}|}{2}\right) + \frac{\mathbf{v}}{|\mathbf{v}|}\cdot \begin{bmatrix}\mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} \sin\left(\frac{|\mathbf{v}|}{2}\right)

で対応し、U \in SU(2)とR \in SO(3)が

U(\mathbf{w}\cdot \mathbf{\sigma}) U^\dagger = (R\mathbf{w})\cdot \mathbf{\sigma}

で対応し、それぞれのリー代数は、

\begin{align*} \begin{bmatrix} E_1\\ E_2\\ E_3 \end{bmatrix} \Leftrightarrow \begin{bmatrix} \frac{-i}{2}\sigma_1\\ \frac{-i}{2}\sigma_2\\ \frac{-i}{2}\sigma_3 \end{bmatrix} \Leftrightarrow \begin{bmatrix} \frac{1}{2}\mathbf{i}\\ \frac{1}{2}\mathbf{j}\\ \frac{1}{2}\mathbf{k} \end{bmatrix} \end{align*}

で対応するということです。

角速度の反映

物体の回転姿勢はあきらかにSO(3)の自由度を持ちます。基準座標系の基底ベクトルで、物体に固定された座標系の基底ベクトルを展開したときの成分のなす行列をRとすればR \in SO(3)です。物体の姿勢は時間によって変化するので、R(t) \in SO(3)とし、これの時間差を取ると

R(t+dt) - R(t) = R(t)(R(t)^{-1}R(t+dt) - 1)

においてR(t)^{-1}R(t+dt)はdtが小さい時、単位元周りの微分係数になるため、対応するリー代数\mathfrak{so}(3)の値となります。したがって、

\frac{d}{dt}R(t) = R(t)\Omega(t)

のように、\Omega \in \mathfrak{so}(3)のR(t)による左移動になっています。R(t)\Omega(t)は、基準座標から見たときの回転運動なので、\Omega(t)は、物体に固定されたローカルな座標系からみた回転運動、特に角速度行列と思うことができます。

さて、IMUには、角速度センサと加速度センサがついています。IMUがついた物体を動かすとき、明らかにこのIMUは、物体に密着した座標系で角速度を計測しています。\Omegaを計測しているわけですが、実際に得られるのは3つの値であり、これはSO(3)のリー代数の基底E_iで展開したときの成分だと思えます。

ということは、もし初期状態に正確な姿勢R(0)が手に入っていたとしたら、IMUが出力する角速度\omega^i(t)を使って

\frac{d}{dt}R(t) = R(t)(\omega(t)^i E_i)

を解いてやれば、系の姿勢がわかることになります。

しかし、R \in SO(3)は3\times 3行列なので、保持するのが面倒です。そこで、SU(2)なら四元数を使って格納できるので、これを変わりに使うことを考えます。R(0)に対応する初期四元数Q(0)があるとします。リー代数は前節でみたような対応しているのだから、四元数を用いた方程式

\frac{d}{dt}Q(t) = Q(t)\left(\frac{1}{2}\omega(t) \cdot \begin{bmatrix}\mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} \right)

を解けばいいことになります。もし、これをSO(3)の元として、なにかに作用させたいなら、随伴作用を使って取り出します。方程式を解くと言っても、実際にはIMUを十分高速に動かし、得られる値に対して、上記を差分方程式として近似して解くことになります。

誤差修正

この方式の問題は、誤差が蓄積するということです。単に陽的に足していっているだけなので、時間とともに誤差が蓄積してQ(t)の値は信用ならないものになっていきます。またこの問題がなくても、最初にQ(0)を正確な値といして知っていなくてはなりません。

ここで、アンカーになるベクトルを外から与えてやることで、これの修正を同時に行うことを考えます。
つまり、基準座標系になにか固定された既知のベクトルがあり、それをIMUが察知することで、現在のQ(t)が「どれだけ間違っているか?」を教えてやり、これに対する自動修正を行わせます。

具体的には、基準座標系において、重力加速度は通常一定で鉛直下向きにかかっています。等価原理からこれはIMUの加速度センサ上は逆方向の加速度として検出されます。基準座標系でのこのベクトルを\mathbf{g}とします。四元数基底をまとめて

\mathbf{L} = \begin{bmatrix}\mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix}

としておきます。もし現在の姿勢を表す四元数/SU(2)であるQ(t)が正確なら、(物体の加速度が重力よりも十分小さいとき)随伴作用を用いて

Q(t) (\mathbf{g}\cdot \mathbf{L}) Q(t)^{\dagger} - \mathbf{a}(t) \cdot \mathbf{L} = 0

が成り立つはずです。ここで\mathbf{a}(t)は、IMUが検出する加速度ベクトルとします。しかしQ(t)が正確でないときはこれは一般に成り立ちません。そこで、Q(t)を単に四変数の値として、これの2乗ノルムを損失関数とした勾配を減ずることで、逐次的に補正します。

F(w,\mathbf{q}|\mathbf{a}(t)) = \|(w+\mathbf{q}\cdot \mathbf{L}) (\mathbf{g}\cdot \mathbf{L}) (w-\mathbf{q}\cdot \mathbf{L}) - \mathbf{a}(t) \cdot \mathbf{L}\|^2

として\nabla_{w,q} F \cdot [1,\mathbf{i},\mathbf{j},\mathbf{k}]^\topを姿勢更新の式に追加します。

具体的に計算すると、まず

\begin{align*} & (w+\mathbf{q}\cdot \mathbf{L}) (\mathbf{g}\cdot \mathbf{L}) (w-\mathbf{q}\cdot \mathbf{L})\\ = & 2w(\mathbf{q}\times \mathbf{g})\cdot \mathbf{L} + 2(\mathbf{q}\cdot \mathbf{g})(\mathbf{q}\cdot \mathbf{L}) + (w^2 - |\mathbf{q}|^2)(\mathbf{g}\cdot \mathbf{L}) \end{align*}

ここで、重力/加速度の大きさを考えず、基準座標系に固定して\mathbf{g}=[0,0,1]^\topとおくと

\begin{align*} = & \begin{bmatrix} 2(wq_2+q_1q_3)\\ 2(-wq_1+q_2q_3)\\ 1-2(q_1^2+q_2^2) \end{bmatrix} \cdot \mathbf{L} \end{align*}

これに\mathbf{a}\cdot \mathbf{L}を引いた2乗ノルムの微分なので、勾配は

\begin{align*} \nabla_{w,q} F = 2\begin{bmatrix} 2q_2 & -2q_1 & 0 \\ 2q_3 & -2w & -4q_1\\ 2w & 2q_3 & -4q_2\\ 2q_1 & 2q_2 & 0 \end{bmatrix} \begin{bmatrix} 2(wq_2+q_1q_3) -a(t)_1\\ 2(-wq_1+q_2q_3) -a(t)_2\\ 1-2(q_1^2+q_2^2) -a(t)_3 \end{bmatrix} \end{align*}

となります。一応2乗の微分なので係数2をつけましたが、以下では規格化するのでどうでもよいです。これを先の更新式に追加し、

\begin{align*} \frac{d}{dt}Q(t) = Q(t)\left(\frac{1}{2}\omega(t) \cdot \begin{bmatrix}\mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} \right) - \beta \frac{\nabla_{w,q} F(Q(t)|\mathbf{a}(t))}{|\nabla_{w,q} F(Q(t)|\mathbf{a}(t))|}\cdot \begin{bmatrix}1\\ \mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} \end{align*}

ついで毎フレームの更新時に、単位四元数から逸脱する可能性があるので、これを規格化するようにします。

Q(t+dt) = \mathrm{Norm}\left( Q(t) + Q(t)\left(\frac{1}{2}\omega(t) \cdot \begin{bmatrix}\mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} \right)dt - \beta \frac{\nabla_{w,q} F(Q(t)|\mathbf{a}(t))}{|\nabla_{w,q} F(Q(t)|\mathbf{a}(t))|}\cdot \begin{bmatrix}1\\ \mathbf{i}\\ \mathbf{j}\\ \mathbf{k}\end{bmatrix} dt\right)

ここで\mathrm{Norm}(Q)=\frac{Q}{|Q|}とします。

ここでは重力加速度を参照しましたが、他に地磁気センサーがあればそれも利用できます。というより、重力加速度の向きだけでは、基準座標系を固定できないので、まだ1軸の不定性があり、例えばZ軸周りの誤差蓄積によるドリフトは回避できません。追加のセンサーが利用できる場合も、同様にその基準ベクトルを現在の推定姿勢で変換し、勾配を追加することで行います。

M5Atomで実装

四元数関連の型と状態更新を定義します。

#include "types.h"

Qt conj(const Qt &q) {
    return {q.w, -q.x, -q.y, -q.z};
}

Qt add(const Qt &q1, const Qt &q2) {
    return {q1.w+q2.w, q1.x+q2.x, q1.y+q2.y, q1.z + q2.z};
}

Qt prod(const Qt &q1, const Qt &q2) {
    return {
        q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z,
        q1.x * q2.w + q1.w * q2.x + q1.y * q2.z - q1.z * q2.y,
        q1.y * q2.w + q1.w * q2.y + q1.z * q2.x - q1.x * q2.z,
        q1.z * q2.w + q1.w * q2.z + q1.x * q2.y - q1.y * q2.x
    };
}

float norm(const Qt &q) {
    float ll = q.w*q.w + q.x*q.x + q.y*q.y + q.z*q.z;
    return sqrtf(ll);
}

float norm3(const Vec &v) {
    float ll = v.x*v.x + v.y*v.y + v.z*v.z;
    return sqrtf(ll);
}

Qt mult(const Qt &q, float k) {
    return {q.w*k,q.x*k,q.y*k,q.z*k};
}

Vec mult3(const Vec &v, float k) {
    return {v.x*k,v.y*k,v.z*k};
}

Qt gradientLoss(const Qt &q, const Vec &a) {
    float lx = 2.0f * ( q.w * q.y + q.x * q.z) - a.x;
    float ly = 2.0f * (-q.w * q.x + q.y * q.z) - a.y;
    float lz = 1.0f - 2.0f * (q.x * q.x + q.y * q.y) - a.z;
    float w = 2.0f * (q.y * lx - q.x * ly);
    float x = 2.0f * (q.z * lx - q.w * ly - 2.0f * q.x * lz);
    float y = 2.0f * (q.w * lx + q.z * ly - 2.0f * q.y * lz);
    float z = 2.0f * (q.x * lx + q.y * ly);
    return {w,x,y,z};
}


Qt update(const Qt &prev, const Vec &gyro, const Vec &accel, float beta, float dt) {
    // 加速度は正規化
    Vec accel_normal = accel;
    float a_norm = norm3(accel);
    if (a_norm > 0.0f) {
        accel_normal = mult3(accel, 1.0f / a_norm);
    }

    Qt omega = {0, gyro.x, gyro.y, gyro.z};
    Qt q_dot_gyro = mult(prod(prev, omega), 0.5f);
    Qt grad = gradientLoss(prev,accel_normal);
    // 勾配は正規化してbetaだけで強さを制御
    float g_norm = norm(grad);
    if (g_norm > 0.0f) {
        grad = mult(grad, 1.0f / g_norm);
    }
    Qt q_dot = add(q_dot_gyro, mult(grad, -beta));
    
    // 次の推定姿勢
    Qt next = add(prev, mult(q_dot, dt));
    float n = norm(next);
    return mult(next, 1.0f / n);
}

ヘッダ:

#ifndef TYPES_H
#define TYPES_H

#include <Arduino.h>

struct Qt {
    float w, x, y, z;
};

struct Vec {
    float x, y, z;
};

Qt conj(const Qt &q);
Qt prod(const Qt &q1, const Qt &q2);
Qt mult(const Qt &q, float k);
Vec mult3(const Vec &q, float k);
Qt add(const Qt &q1, const Qt &q2);
Qt gradientLoss(const Qt &q, const Vec &a);
Qt update(const Qt &prev, const Vec &gyro, const Vec &accel, float beta, float dt);

float norm(const Qt &q);
float norm3(const Vec &q);

#endif

せっかくなので、旋回角度をマウス入力にします。
Atomで使えるBLE Mouseのライブラリがあったので使わせてもらいます。
https://github.com/T-vK/ESP32-BLE-Mouse

メインルーチン側もGemini氏に教えてもらいつつポチポチ

#include <M5Unified.h>
#include "types.h"
#include <BleMouse.h>

Qt currentPose = {1.0f, 0.0f, 0.0f, 0.0f}; 
float beta = 0.1f;                         
uint32_t lastMicros = 0;   
Vec gyroOffset = {0, 0, 0};

// マウス関連
BleMouse bleMouse("AtomS3 AirMouse", "M5Stack");
float mouseSensitivity = 1500.0f;

// 開始時に角速度バイアスを計測
void calibrateGyro() {
    Serial.println("Calibrating...");
    gyroOffset.x = 0.0f;
    gyroOffset.y = 0.0f;
    gyroOffset.z = 0.0f;
    float gx, gy, gz;
    int samples = 1000;
    for (int i = 0; i < samples; i++) {
        M5.Imu.getGyroData(&gx, &gy, &gz);
        gyroOffset.x += gx;
        gyroOffset.y += gy;
        gyroOffset.z += gz;
        delay(2);
    }
    gyroOffset.x /= samples;
    gyroOffset.y /= samples;
    gyroOffset.z /= samples;
    Serial.println("Done!");
}

// マウス送信
void mouseDiff(const Qt prevPose, const Qt newPose) {
  Qt diff = prod(conj(prevPose),newPose);
  // float deltax = (int)(diff.y * mouseSensitivity);
  float deltax = -(int)(diff.z * mouseSensitivity);
  float deltay = -(int)(diff.x * mouseSensitivity);
  bleMouse.move(deltax,deltay);
}

void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);
    Serial.begin(1500000);
  
    if (!M5.Imu.begin()) {
        while (true) { delay(1); }
    }
    bleMouse.begin();
    lastMicros = micros();

    Serial.println("Wait for calibration...");
    delay(2000);
    calibrateGyro();
}

void loop() {
    M5.update(); 

    // 経過時間チェック
    uint32_t currentMicros = micros();
    uint32_t diff = currentMicros - lastMicros;
    // 10msで1フレーム
    if (diff < 10000) return; 
    float dt = diff / 1000000.0f; 
    lastMicros = currentMicros;

    // IMU読み出し
    float ax, ay, az, gx, gy, gz;
    M5.Imu.getAccelData(&ax, &ay, &az);
    M5.Imu.getGyroData(&gx, &gy, &gz);

    // オフセットを差し引き
    gx -= gyroOffset.x;
    gy -= gyroOffset.y;
    gz -= gyroOffset.z;

    // ラジアンへ変換
    Vec gyro_rad = { gx * ((float)PI / 180.0f), gy * ((float)PI / 180.0f), gz * ((float)PI / 180.0f) };
    Vec accel = { ax, ay, az };

    Qt newPose = update(currentPose, gyro_rad, accel, beta, dt);
    Serial.printf("W:%.3f X:%.3f Y:%.3f Z:%.3f | Hz:%.1f\r\n", 
                  newPose.w, newPose.x, newPose.y, newPose.z, 1.0f / dt);

    if (bleMouse.isConnected() && M5.BtnA.isPressed()) {
      mouseDiff(currentPose,newPose);
    }
    currentPose = newPose;
}
W:-0.555 X:0.006 Y:-0.004 Z:-0.832 | Hz:100.0
W:-0.555 X:0.006 Y:-0.004 Z:-0.832 | Hz:100.0
W:-0.555 X:0.006 Y:-0.005 Z:-0.832 | Hz:100.0
W:-0.555 X:0.005 Y:-0.005 Z:-0.832 | Hz:100.0
W:-0.555 X:0.005 Y:-0.004 Z:-0.832 | Hz:100.0
W:-0.555 X:0.006 Y:-0.003 Z:-0.832 | Hz:100.0
W:-0.555 X:0.007 Y:-0.003 Z:-0.832 | Hz:100.0
W:-0.555 X:0.006 Y:-0.003 Z:-0.832 | Hz:100.0
W:-0.555 X:0.006 Y:-0.004 Z:-0.832 | Hz:100.0

四元数の値をそのまま持ってるので、水平から90度傾けるとXかYが0.7(\sim 1/\sqrt{2})くらいまで変化します。

\beta=0.1だと、誤差修正は結構遅く、推定が思いっきりズレている状態から収束まで数秒かかります。変な姿勢でしばらくおいてから水平に戻すと、ドリフトのせいで全然違う角度から修正が始まるので数秒間は信頼できない値になります。かといって\betaを大きくするとHIDとして利用するにはノイジーな感じになります。収束の過程でやはり拘束できていないZ軸の回転が蓄積したりします。

(追記)これだとまだ様子がわかりづらいのでここでもGemini氏にシリアルポート->ブラウザ経由で可視化するページを作ってもらいます

(以下ほぼGeminiポン出し(!!))

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>AtomS3 Orientation Visualizer</title>
    <style>
        body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: sans-serif; }
        #ui { position: absolute; top: 20px; left: 20px; color: white; z-index: 10; background: rgba(0,0,0,0.6); padding: 15px; border-radius: 8px; border: 1px solid #444; }
        button { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 16px; transition: 0.2s; }
        button:hover { background: #0056b3; }
        #stats { margin-top: 10px; font-family: monospace; font-size: 12px; color: #aaa; }
    </style>
</head>
<body>
    <div id="ui">
        <h2>AtomS3 IMU Visualizer</h2>
        <button id="connect">シリアルポートに接続</button>
        <div id="stats">左ドラッグ: 回転 / 右ドラッグ: 移動 / スクロール: ズーム</div>
    </div>

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        // --- Three.js Setup ---
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // カメラ操作 (OrbitControls)
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true; // 滑らかな動き

        // 照明
        const light = new THREE.DirectionalLight(0xffffff, 1);
        light.position.set(5, 10, 7.5);
        scene.add(light);
        scene.add(new THREE.AmbientLight(0x404040));

        // 地面 (これがあると「下」が分かりやすい)
        const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0x444444);
        scene.add(gridHelper);

        // ワールド座標軸 (Red:X, Green:Y, Blue:Z)
        const worldAxes = new THREE.AxesHelper(5);
        scene.add(worldAxes);

        // AtomS3 キューブ
        // Madgwickの座標系に合わせるため、少し縦長な箱にする
        const geometry = new THREE.BoxGeometry(2, 0.4, 1.5);
        const materials = [
            new THREE.MeshPhongMaterial({color: 0xff0000}), // +X (右面)
            new THREE.MeshPhongMaterial({color: 0xaa0000}), // -X
            new THREE.MeshPhongMaterial({color: 0x00ff00}), // +Y (上面)
            new THREE.MeshPhongMaterial({color: 0x00aa00}), // -Y
            new THREE.MeshPhongMaterial({color: 0x0000ff}), // +Z (正面)
            new THREE.MeshPhongMaterial({color: 0x0000aa})  // -Z
        ];
        const cube = new THREE.Mesh(geometry, materials);
        scene.add(cube);

        // キューブに固定されたローカル軸
        const localAxes = new THREE.AxesHelper(2);
        cube.add(localAxes);

        camera.position.set(5, 5, 8);
        controls.update();

        function animate() {
            requestAnimationFrame(animate);
            controls.update(); // Damping を有効にするために必要
            renderer.render(scene, camera);
        }
        animate();

        // --- Serial Port Logic ---
        const connectBtn = document.getElementById('connect');
        const statsDiv = document.getElementById('stats');

        connectBtn.addEventListener('click', async () => {
            try {
                const port = await navigator.serial.requestPort();
                await port.open({ baudRate: 1500000 });
                connectBtn.textContent = "Connected";
                
                const decoder = new TextDecoderStream();
                port.readable.pipeTo(decoder.writable);
                const reader = decoder.readable.getReader();
                let buffer = "";

                while (true) {
                    const { value, done } = await reader.read();
                    if (done) break;
                    buffer += value;
                    const lines = buffer.split("\n");
                    buffer = lines.pop();
                    for (const line of lines) {
                        const match = line.match(/W:([-.\d]+)\s+X:([-.\d]+)\s+Y:([-.\d]+)\s+Z:([-.\d]+)/);
                        if (match) {
                            const w = parseFloat(match[1]);
                            const x = parseFloat(match[2]);
                            const y = parseFloat(match[3]);
                            const z = parseFloat(match[4]);

                            // 座標軸配置に要注意
                            cube.quaternion.set(y, z, x, w);
                            
                            statsDiv.innerHTML = `W:${w.toFixed(3)} X:${x.toFixed(3)} Y:${y.toFixed(3)} Z:${z.toFixed(3)}`;
                        }
                    }
                }
            } catch (err) { console.error(err); }
        });

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

例によってfile://だとまずそうなので適当にホスティングします。とりあえずLive Server
を使いました。

いい感じに追従しているように見えますが、時々ドリフトして勝手にゆっくり回ったりします。やはりもう一本拘束が欲しいところです。

Discussion