はじめに

前回の記事DeviceOrientation Eventの値をグラフにリアルタイム表示してみました。
今回はそのDeviceOrientation EventOrientation Sensor APIを使って、スマホの姿勢をCSSで反映させる方法を検討します。

だいぶ「予備知識」の項目が長くなってしまったので、処理だけ見たければここまで飛んでください。

ソース
デモ

予備知識

センサーの座標系

スマホの画面を上に、画面の頭を北に向けて地面に置いたとき、

  • 東西にX軸(東に向かって正)
  • 南北にY軸(北に向かって正)
  • 上下にZ軸(空に向かって正)

となります。
また、原点から各軸の正の方向を見て時計回りを、正の回転とします。
いわゆる右手系です。
(例の右手の法則をかたどって親指がX、人差し指がY、中指がZとなる。右手で親指を軸の正の方向に向けて軸を掴むと、残りの指が正の回転方向を指す。)

https://triple-underscore.github.io/orientation-sensor-ja.html
出典:Orientation Sensor(日本語訳)
https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Orientation_and_motion_data_explained
出典:Orientation and motion data explained - Developer guides | MDN
https://triple-underscore.github.io/deviceorientation-ja.html
出典:DeviceOrientation Event Specification (日本語訳)

CSSの座標系

ご存知の方も多いでしょうが、CSSでは画面の

  • 左右がX軸(右に向かって正)
  • 上下がY軸(下に向かって正)

となっています。さらに3Dを表現する場合は、画面に垂直なZ軸(視点側に向かって正)を加えます。
そうすると、いわゆる左手系の形になります。
(例の左手の法則をかたどって、親指がX、人差し指がY、中指がZとなる。 )

See the Pen CSS coordinate system by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

左手系の回転方向は右手系の逆回りとなります。
すなわち、各軸の正の方向から原点を見て時計回りを、正の回転とします。
(左手で親指を軸の正の方向に向けて軸を掴むと、残りの指が正の回転方向を指す。)

例えば上図でのY軸はrotateZ(90deg)としてますが、これはZ軸を中心にして、正の方向(視点)から原点(画面)を見て時計回りに90度回転させることを意味しています。
同様にZ軸はrotateY(-90deg)ですが、これはY軸を中心にして、正の方向(画面の下)から原点(画面の上)を見て時計回りに90度回転させています。

ここで重要なのは、Y軸を中心とした回転の方向がセンサーとCSSで見かけ上一致することです。

センサでは原点(画面中心)から正の方向(画面上部)を見て時計回りが正の方向
CSSでは正の方向(画面下部)から原点(画面上部)を見て時計回りが正の方向

これは、CSSの座標系がセンサーの座標系のY軸の方向を反対にした形となっているためです。
X軸とZ軸では、軸の方向は同じで回転方向が逆になります。

DeviceOrientation Eventで取得できる値

DeviceOrientationEvent🔗またはDeviceOrientationAbsoluteEvent🔗では姿勢に関してオイラー角の情報、すなわち以下3つの値を取得できます。

  • alpha: Z軸での回転。0~360(北が0)
  • beta: X軸での回転。-180~180(水平が0)
  • gamma: Y軸での回転。-90~90(水平が0)

ref.

ここで意識しなければならないのは、上記の回転が適用される順番です。

  1. 画面を上にして地面に置いて、北を向けた状態から
  2. Z軸を中心に$\alpha$度回転
  3. 回転後のX'軸を中心に$\beta$度回転
  4. 回転後のY''軸を中心に$\gamma$度回転

というZXYの順番で回転させると考えます。この順番も後々重要になります。
DeviceOrientation Event Specification (日本語訳)4.1. deviceorientation イベント

ちなみに、オイラー角表現による回転の順番についてはジンバルをイメージすると理解しやすいと思います。
下記動画が大変分かりやすかったです。
ジンバルロックとは?ジンバルロックを回避する方法をわかりやすく解説します【Maya作業画面】 - YouTube

Orientation Sensor APIで取得できる値

AbsoluteOrientationSensor🔗またはRelativeOrientationSensor🔗では、クォータニオンの情報を取得できます。
具体的には、$(V_x, V_y, V_z)$で表せる単位ベクトルを軸に角度$\theta$だけ回転させた場合、下記4つの値が取得されます。
$[V_x\times\sin\frac{\theta}{2}\ ,\ V_y\times\sin\frac{\theta}{2}\ ,\ V_z\times\sin\frac{\theta}{2}\ ,\ \cos\frac{\theta}{2}]$

ref. Orientation Sensor(日本語訳)

本記事ではクォータニオンについて上記定義ぐらいに留め、深入りしないことにします。(筆者がよく分かってない)
以下の記事がとても詳しく書かれているので、興味のある方はぜひ。
クォータニオン (Quaternion) を総整理! ~ 三次元物体の回転と姿勢を鮮やかに扱う ~ - Qiita

CSSのtransformで変換関数が評価される順番

突然ですが、下の牛さんをYZ平面のY軸上に立たせたいとしたら、どのように回転させればよいでしょうか。
例によって見やすいように視点を移動した風にしてますが、各軸はCSSの座標系を表すものとします。

See the Pen 210105002 by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

上記のセンサーで回転が適用される順番と同様に考えれば、

  1. Z軸を中心に90度回転
  2. 回転後のX'軸を中心に−90度回転

と動かせばよさそうです。
これを実現するCSSは以下のようになります。

#cow2 {
  transform: rotateZ(90deg) rotateX(-90deg);
}

See the Pen 210105003 by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

期待通りの結果になりました。
ところで、上記transformのプロパティに記述した変換関数は、本当にrotateZ()rotateX()の順番に評価されたのでしょうか?
というのも、上ではセンサーのオイラー角と同様に考えてみましたが、以下の順番で動かすと考えることもできます。すなわち、

  1. X軸を中心に−90度回転
  2. 元のZ軸を中心に90度回転

こうして考えた場合、変換関数はrotateX()rotateZ()の順番に(つまり右から左に)評価されたことになります。
同じ処理なのに考え方によって順番が逆になるので困惑してしまいますが、結論を言うと内部の処理としては後者の考え方が適切です。(次節で確認します。)

が、あくまでも"考え方"としてであれば、状況に応じて分かりやすいように捉えればいいんじゃないかと思います。例えば、

  • 回転されるオブジェクトの立場になって、自分自身の軸を中心に回転していくなら左から右に
  • 画面から見ている立場として、画面の軸を中心に回転させてくなら右から左に

または、すでにいくつかの変換関数が適用されている状態でさらに回転させたい場合、

  • オブジェクトの軸で回転を追加したいなら右端に追記
  • 画面の軸で回転を追加したいなら左端に追記

といった感じで。

余談ですが上の回転は以下の方法でも実現可能です。

#cow3 {
  transform: rotateY(-90deg) rotateZ(90deg);
}
#cow4 {
  transform: rotateX(-90deg) rotateY(-90deg);
}

See the Pen 21010504 by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

各軸回りに回転させて姿勢を特定するという方法では、回転させる軸の順番で複数のパターンがありえます。
逆に言えば、各軸で回転させる角度が分かっていても、回転させる軸の順番によって結果が変わってしまいます。
オイラー角の説明の際に順番を意識しなければならないと述べたのはこれが理由です。

CSSのmatrix3dについて

3次元の変換を4×4の行列で表現したものです。🔗
変換行列については検索すれば数多の情報が出てきますが、例えばなんとなく雰囲気を感じたければこちらだったり、そもそも行列とは?なんで変換?という方はこちらが分かりやすかったです。
(筆者も文系出身の行列未経験者だったので、今回色々調べて勉強になりました)

さて、今回はそんな行列の中でも回転行列に注目します。
x軸、y軸、z軸周りの回転を表す回転行列は、それぞれ以下のようになります。🔗

R_x(\theta) = \begin{bmatrix}
1 & 0 & 0 & 0 \\
1 & \cos\theta & -\sin\theta & 0 \\
0 & \sin\theta & \cos\theta & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} \\
\,\\
R_y(\theta) = \begin{bmatrix}
\cos\theta & 0 & \sin\theta & 0 \\
0 & 1 & 0 & 0 \\
-\sin\theta & 0 & \cos\theta & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} \\
\,\\
R_z(\theta) = \begin{bmatrix}
\cos\theta & -\sin\theta & 0 & 0 \\
\sin\theta & \cos\theta & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix}

この回転行列ですが、今回対象とするセンサーの右手座標系でも、CSSの左手座標系でも共通です。
例えば点$(1,0,0)$をZ軸で90度回転させると点$(0,1,0)$となります。これはZ軸の正の方向から見て、センサーの右手系の座標では反時計回り、CSSの左手系の座標では時計回りとなっていて、各座標系での正の回転方向と一致しています。
他の軸についても同様で、つまり特定の座標系で完結している限り、回転行列自体は回転の方向には関与しないのです。

それでは回転行列をCSSに適用するべくmatrix3dの形にしてみましょう。今回はJSで以下の関数を定義しました。

const d2r = deg => deg/180 * Math.PI;
function rotateX(deg) {
    rad = d2r(deg);
    return [
        1, 0, 0, 0,
        0, Math.cos(rad), Math.sin(rad), 0,
        0, - Math.sin(rad), Math.cos(rad), 0,
        0, 0, 0, 1
    ]
}
function rotateY(deg) {
    rad = d2r(deg);
    return [
        Math.cos(rad), 0, - Math.sin(rad), 0,
        0, 1, 0, 0,
        Math.sin(rad), 0, Math.cos(rad), 0,
        0, 0, 0, 1
    ]
}

function rotateZ(deg) {
    rad = d2r(deg);
    return [
        Math.cos(rad), Math.sin(rad), 0, 0,
        - Math.sin(rad), Math.cos(rad), 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    ]
}
function dot(m1, m2) {
    // const retval = [Array(4), Array(4), Array(4), Array(4)];
    const retval = Array(16);

    for (i = 0; i < 4; i++) {
        for (j = 0; j < 4; j++) {
            let c = 0;
            for (k = 0; k < 4; k++) {
                // c += m1[i][k] * m2[k][j];
                c += m1[i * 4 + k] * m2[k * 4 + j];
            }
            // retval[i][j] = c;
            retval[i * 4 + j] = c;
        }
    }
    return retval;
}

d2r()は度数法から弧度法への変換、dot()は行列の積を返します。当初は2次元配列で扱おうとしていたものを1次元配列に直した名残がありますが、やはり2次元配列で考えたほうが分かりやすいですね。
さて、注目してほしいのはrotateX()rotateY()rotateZ()で$\sin\theta$のマイナス符号が入れ替わっている点です。
なぜこうなるのかと言うと、matrix3dは4×4行列の16個の値を列優先データ順(column-major order)で表現したものだからです。

matrix3d() = matrix3d( <number>#{16} )
specifies a 3D transformation as a 4x4 homogeneous matrix of 16 values in column-major order.
CSS Transforms Module Level 2(太字筆者)

matrix3d() 関数は16の値で指定します。列優先の順で記述します
matrix3d(a1, b1, c1, d1, a2, b2, c2, d2, a3, b3, c3, d3, a4, b4, c4, d4)
matrix3d() - CSS: カスケーディングスタイルシート | MDN(太字筆者)

これは単にデータ保持形式を意味していて、以下のa〜d行1〜4列の行列の、列ごとに連続して値を保持しますよ〜ということのようです。
ref. 列優先(Column-major),行優先(Row-major)は複数ある - Qiita

\begin{bmatrix}
a1 & a2 & a3 & a4 \\
b1 & b2 & b3 & b4 \\
c1 & c2 & c3 & c4 \\
d1 & d2 & d3 & d4 \\
\end{bmatrix} → [a1, b1, c1, d1, a2, b2, c2, d2, a3, b3, c3, d3, a4, b4, c4, d4]

で、この列優先で並べた配列の要素を4つずつで改行すると転置行列に見えるので、ちょうど$\sin\theta$のマイナス符号が入れ替わっているような姿となっている訳です。

ここで注意喚起しておきたいことがあります。
上では「特定の座標系で完結している限り、回転行列自体は回転の方向には関与しない」と述べました。
しかし今回は、右手系のセンサーの回転を左手系のCSSに適用させようという試みです。Y軸の回転方向は見かけ上一致しますが、X軸とZ軸では反対方向に回転するので、その分を変換しなければなりません。
具体的には、センサーのZ軸で$\alpha$度したらCSSのZ軸では$-\alpha$度回転させることになります。(X軸でも同様)
これを回転行列に当てはめると以下のように転置行列となり、上で定義した関数と同じように見えてしまいます。

R_z(-\alpha) = \begin{bmatrix}
\cos(-\alpha) & -\sin(-\alpha) & 0 & 0 \\
\sin(-\alpha) & \cos(-\alpha) & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} = \begin{bmatrix}
\cos\alpha & \sin\alpha & 0 & 0 \\
-\sin\alpha & \cos\alpha & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix}

実装の考え方は様々あるかと思いますが、今回定義したrotateX()rotateY()rotateZ()の各関数は与えられた角度の回転行列を列優先の順で記述した配列を返すだけであり、座標系の変換については考慮していません。
これを混同してしまうとドツボにはまる(はまってしまった…)ので、何卒ご注意ください。


回転行列$R(\theta)$の列優先データ順での表現と、逆回転を表す行列$R(-\theta)$は、どちらも転置行列$R^\mathsf{T}(\theta)$となる。混同しないよう注意。 


それではこの関数でmatrix3dを使ってみましょう。
再度牛さんに登場してもらって、先程と同じ回転をさせるには以下のようにします。

$("cow3").style.transform = `matrix3d(${dot(rotateX(-90),rotateZ(90))})`;

See the Pen 21010505 by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

回転行列の積も回転行列になるわけですが、ここでも順番が重要になります。rotateX(-90)の回転行列にrotateZ(90)の回転行列を掛ける場合、

  1. X軸を中心に-90度回転
  2. 元のZ軸を中心90度回転

という順番で回転させる行列を表します。
この順番には見覚えがありますね。そうです、CSSでのtransform: rotateZ(90deg) rotateX(-90deg);という記述で変換関数を右から評価したと考える場合と一致します。
先だって「変換関数は右から評価されると考えるのが適切」と述べましたが、以下仕様に沿って詳しく確認してみます。

まず、transformプロパティに記述された各変換関数は、左から右に(from left to right)post-multiplyされます。

Multiply by each of the transform functions in transform property from left to right
ref.CSS Transforms Module Level 2(太字筆者)

Post-multiply all <transform-function>s in <transform-list> to transform.
ref. CSS Transforms Module Level 2(太字筆者)

post-multiply
post-multiplied

Term A post-multiplied by term B is equal to A · B.
ref. CSS Transforms Module Level 1

いきなり難解な表現になりましたが、要はtransform: A B C;という記述があったとき、$(C \times (B \times A))$として計算するということです。これは結合法則によって$((C \times B) \times A)$と同等です。
(ここらへん、ドキュメント読んでもはっきりと確信が持てませんでした…解釈違い等あればご指摘ください🙇)

そして計算後の値が一つのmatrix3d()(またはmatrix())にまとめられることになります。

A <transform-list> for the computed value is serialized to either one or one function by the following algorithm
CSS Transforms Module Level 2(引用文中の"following algorithm"というのが上述の処理)

回転についてさらに掘り下げると、各軸回りの回転はrotate3d()に置き換え可能です。

rotateX() = rotateX( [ <angle> | <zero> ] )
same as rotate3d(1, 0, 0, <angle>).

rotateY() = rotateY( [ <angle> | <zero> ] )
same as rotate3d(0, 1, 0, <angle>).

rotateZ() = rotateZ( [ <angle> | <zero> ] )
same as rotate3d(0, 0, 1, <angle>), which is a 3d transform equivalent to the 2d transform rotate(<angle>).
CSS Transforms Module Level 2

さらにrotate3d(x, y, z, α)は以下の行列に変換されます。

\begin{bmatrix}
1-2 \cdot(y^2 + z^2)\cdot sq & 2\cdot(x\cdot y\cdot sq - z\cdot sc) & 2\cdot(x\cdot z\cdot sq - y\cdot sc) & 0\\
2\cdot(x\cdot y\cdot sq + z\cdot sc) & 1-2 \cdot(x^2 + z^2)\cdot sq & 2\cdot(y\cdot z\cdot sq - x\cdot sc) & 0\\
2\cdot(x\cdot z\cdot sq - y\cdot sc) & 2\cdot(y\cdot z\cdot sq + x\cdot sc) & 1-2 \cdot(x^2 + y^2)\cdot sq & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} \\
\,\\
sc = \sin(\frac{\alpha}{2})\cdot\cos(\frac{\alpha}{2}) \\
sq = \sin^2(\frac{\alpha}{2})

CSS Transforms Module Level 2 | #Rotate3dDefined

この行列にrotateZ(θ)、すなわちrotate3d(0, 0, 1, θ)を当てはめてみます。

\begin{bmatrix}
1-2 \cdot sq & -2\cdot sc & 0  & 0\\
2\cdot sc & 1-2 \cdot sq & 0 & 0\\
 0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} = \begin{bmatrix}
1-2 \cdot \sin^2\frac{\theta}{2} & -2\cdot \sin\frac{\theta}{2}\cdot\cos\frac{\theta}{2} & 0  & 0\\
2\cdot \sin\frac{\theta}{2}\cdot\cos\frac{\theta}{2} & 1-2 \cdot \sin^2\frac{\theta}{2} & 0 & 0\\
 0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} \\
\,\\
= \begin{bmatrix}
\cos\theta & -\sin\theta & 0  & 0\\
\sin\theta & \cos\theta & 0 & 0\\
 0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix} \\

はい、高校数学の教科書を引っ張り出して2倍角の公式と半角の公式を駆使すると
見覚えのある回転行列になりました。これはrotateX()でもrotateY()でも同様です。
以上より、transform: rotateZ(90deg) rotateX(-90deg);は結局$R_x(-90)\cdot R_z(90)$を計算した行列を列優先でmatrix3d()として変換したものとなり、上のJSのスクリプトはその処理を再現しただけという事になるわけです。

1. DeviceOrientation Event

予備知識編がだいぶ長くなってしまいました…。
以下ではサクサク実装例を見ていきます。

1_1. CSSの回転関数を使う

素直に取得した値で各軸回りに回転させます。
センサーでは以下の順番で回転させるのでした(再掲)。
1. Z軸を中心に$\alpha$度回転
2. 回転後のX'軸を中心に$\beta$度回転
3. 回転後のY''軸を中心に$\gamma$度回転

これをCSSで考えると、transformプロパティで左から右に考えるのでした。(考え方)
さらに、センサーの座標系とCSSの座標系では、X軸とZ軸で回転方向が逆になるのでした。(CSSの座標系)

という訳で、以下のようなコードになります。

window.addEventListener("deviceorientation", ev => {
    document.getElementById("cube1_1").style.transform
        = `rotateX(90deg) rotateZ(${- ev.alpha}deg) rotateX(${- ev.beta}deg) rotateY(${ev.gamma}deg)`;
})

最初のrotateX(90deg)は視点を横からにした風の操作です。
そのままの状態では上(センサーのZ軸の正の方向)から見た視点となるので、スマホを手に持って画面を見る視点になるようにしました。
デモではサイコロの1がスマホの画面となります。(以下同)

1_2. 行列計算する

上でやっていることをJSで再現するだけです。

window.addEventListener("deviceorientation", ev => {

    const matrix3d = dot(dot(rotateY(ev.gamma), rotateX(-ev.beta)), rotateZ(-ev.alpha));
    document.getElementById("cube1_2").style.transform
        = `rotateX(90deg) matrix3d(${matrix3d})`;

})

2. RelativeOrientationSensor

2_1. populateMatrixメソッドを使う(失敗)

OrientationSensorインターフェースにはpopulateMatrix()というメソッドがあります。

const options = { frequency: 15, referenceFrame: 'device' };
const sensor1 = new RelativeOrientationSensor(options);

sensor1.start();

sensor1.addEventListener('reading', function (ev) {
    const targetMatrix = new Float32Array(16);
    sensor1.populateMatrix(targetMatrix);

    document.getElementById("cube2_1").style.transform = `rotateX(90deg) matrix3d(${targetMatrix}) `;
}

実際の動きを見ると、CSS的にオイラー角をtransform: rotateY(-γ) rotateX(-β) rotateZ(-α);と記述した場合と同じ回転になりました。
行列を転置してみたりY軸で反転させてみたりしましたがダメ…(原因は次節)

2_2. populateMatrixメソッドの処理を実装

上のリンク先でも載っている計算を実装してみます。

    const x = this.quaternion[0];
    const y = this.quaternion[1];
    const z = this.quaternion[2];
    const w = this.quaternion[3];

    const matrix2 = (function (x, y, z, w) {
        return [
            x * x - y * y - z * z + w * w,
            2 * (x * y + z * w),
            2 * (x * z - y * w),
            0,
            2 * (x * y - z * w),
            -x * x + y * y - z * z + w * w,
            2 * (y * z + x * w),
            0,
            2 * (x * z + y * w),
            2 * (y * z - x * w),
            -x * x - y * y + z * z + w * w,
            0,
            0, 0, 0, 1
        ]
    })(x, -y, z, -w);
    document.getElementById("cube2_2").style.transform
        = `rotateX(90deg) matrix3d(${matrix2}) `;

例によって列優先、若干一部の計算が異なるのは $ x^2 + y^2 + z^2 + w^2 = 1$ を使うかどうかの違いです。
実際の計算にあたってはywをマイナス倍してますが、yはY軸反転だから、wは回転が逆方向だからです。

センサーから取得されたクォータニオンでは、ベクトルの先から原点を見て反時計回りが正の回転っぽい。(右手系)

右手系では回転方向は反時計回りを正の向きとして測る
クォータニオン (Quaternion) を総整理! ~ 三次元物体の回転と姿勢を鮮やかに扱う ~ - Qiita

対してCSSのrotate3dは原点を見て時計回りが正の回転です。(左手系)

the rotation is clockwise as one looks from the end of the vector toward the origin.
CSS Transforms Module Level 2

クォータニオンから行列に変換する式をよく見ると、wをマイナス倍すれば転置行列になることが分かるかと思います。

上でpopulateMatrixメソッドをそのまま使ってダメだったのは、こういった操作ができないからですね。
行列の演算でどうにかできるのかなぁ…

2_3. CSSのrotate3d()を使う

クォータニオンって実はほぼほぼrotate3d()の引数と一致するんですよね。
定義再掲

$(V_x, V_y, V_z)$で表せる単位ベクトルを軸に角度$\theta$だけ回転させた場合、下記4つの値が取得されます。
$[V_x\times\sin\frac{\theta}{2}\ ,\ V_y\times\sin\frac{\theta}{2}\ ,\ V_z\times\sin\frac{\theta}{2}\ ,\ \cos\frac{\theta}{2}]$

x, y, zは定数倍してるだけなので方向を表す上では関係なさそう、なので$\cos\frac{\theta}{2}$からθを計算すればよいだけです。

document.getElementById("cube2_3").style.transform
    = `rotateX(90deg) rotate3d(${x}, ${-y}, ${z}, ${2 * Math.acos(-w)}rad)`;

3. 方角の絶対値

ここまでで紹介したコードは全て、方角(alpha値)を相対値を取得します。
以下の方法で(デバイスが対応していれば)絶対値を取得できます。
CSSへの反映はすでに紹介した方法です。

3_1. DeviceOrientAtionabsolute Event

window.addEventListener("deviceorientationabsolute", ev => {
    document.getElementById("cube3_1").style.transform
        = `rotateX(90deg) rotateZ(${- ev.alpha}deg) rotateX(${- ev.beta}deg) rotateY(${ev.gamma}deg) `;
})

3_2. AbsoluteOrientationSensor

const sensor2 = new AbsoluteOrientationSensor(options);
sensor2.start();

sensor2.addEventListener('reading', function (ev) {

    const x = this.quaternion[0];
    const y = this.quaternion[1];
    const z = this.quaternion[2];
    const w = this.quaternion[3];

    document.getElementById("cube3_2").style.transform
        = `rotateX(90deg) rotate3d(${x}, ${-y}, ${z}, ${2 * Math.acos(-w)}rad)`;

})

終わりに

センサーやらCSSの3Dやら行列計算やら、初めてづくしで調べながら書いたのでなかなか大変でした…
至らない点も多々あるかと思うので、間違いやご指摘などコメントいただければ嬉しいです!

余談ですが回転行列の仕組みを理解しようと、これも初めてGeoGebraを使ってみました。
回転行列 – GeoGebra
ノンプログラミングでこれだけ多機能、強力なビジュアライズができるウェブアプリがあるのかと感動しました。すごい。