Bun から始める HID 可視化
はじめに
目標
Bun → React → tsx → WebHID → WebGL といった流れで、ブラウザ上で HID レポートを可視化する GUI アプリケーションをつくってみる。
なお筆者は Web も Linux も ECMAScript も全然わかってないので、それを念頭にご笑覧いただきたい。
自問自答
なぜ Bun か
正直に言えば、そもそも Node とか npm の事が分かっていないので、同じ分からないなら余計なシガラミのない新しくて早いやつのほうが良いのでは?というだけで選んだ。
なんでもオルタナ Node ポジションをめぐり Deno (Rust 製) と Bun (Zig 製) が競り合っているのだそうで。
両者比べると、ゼロから React を始めるなら Bun のほうが手数が少ないように見えた。
なぜ React か
当然ながらフロントエンド知識も皆無なので、デファクト・スタンダードっぽいものを選べばよかろう、と思い選んだ。
Bun を選んだ理由とスタンスが異なるようにも思われるが、実際のコーディング作業にかかわる部分だからやっぱり情報 (特に書籍) が多いほうがいいよね。
(そもそも用語「フロントエンド」が実際どっからどこまでの領域を指しているかもよく分かってない、というのもある)
なぜ TypeScript か
javascript 書きたくないというのは勿論だけど、さらに Bun トップページによると
In Bun, every file is transpiled. TypeScript & JSX just work
とのことである。
npm install -g typescript
とか tsc
が要らないのはとてもいい。
(なぜ不要なのかは調べていない)
なぜ HID か
それは筆者が HID レポートの可視化を生業としている HID おじさんだから。
検証環境
- Windows 11 22H2
- WSL2 : Ubuntu 22.04.1
- Microsoft Edge : 107
- bun v0.2.2
おれ、よわいエンジニア、 Linux 、わからない。画面、くろい、こわい。
手順
WSL2 環境の準備
Bun は Windows 向けインストーラーなどを提供していないので、まずは WSL2 環境を用意する。
自分の環境の場合、いつぞやインストールしてあった WSL が version 1 であったので軽くハマった。
PowerShell とかで wsl -l -v
すると、WSL のバージョンを確認できるので、version 1 で動いていた場合は wsl --set-default-version 2
を打つ。
(参考 : WSL 1 から WSL 2 へのバージョンのアップグレード)
古い WSL が入っているとバージョンの切り替えができない(仮想化云々のメッセージが出る)ので wsl --update
したり
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
したりすると(要再起動) wsl --set-default-version 2
が成功する。
(参考 : 手順 3: 仮想マシンの機能を有効にする)
> wsl -l -v
NAME STATE VERSION
* Ubuntu Running 2
Bun のインストール
こちらなどを参考に、React のテンプレートを実行するところまで進める。
Ubuntu のシェル上で
sudo apt install unzip
curl https://bun.sh/install | bash
nano ~/.bashrc
まで実行する。
Bun のインストール時のメッセージに従い、以下を .bashrc に追記して上書き保存。
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
適当なディレクトリを作ってそこに React のテンプレートを生成する。
(参考 : 3 Ways To Use Bun With Create React App)
$ bun create react test-bun-react
$ cd test-bun-react
$ ls -l
total 1008
-rw-r--r-- 1 kndys kndys 842 Oct 16 08:34 README.md
-rwxrwxrwt 1 kndys kndys 3800 Oct 16 08:34 bun.lockb
drwxr-xr-x 11 kndys kndys 4096 Oct 16 08:34 node_modules
-rwxrwxrwt 1 kndys kndys 1000424 Oct 16 08:34 node_modules.bun
-rw-r--r-- 1 kndys kndys 309 Oct 16 08:34 package.json
drwxr-xr-x 2 kndys kndys 4096 Oct 16 08:34 public
drwxr-xr-x 2 kndys kndys 4096 Oct 16 08:34 src
あとは bun dev
するだけで、WSL の中で立てたサーバーにホスト OS である Windows 上の Web ブラウザから localhost
でアクセスできる。便利。 (WSL2 から可能になったのだとか)
$ bun dev
[0.12ms] "node_modules.bun" - 12 modules, 5 packages
[2.00ms] bun!! v0.2.2 (6632135e)
Link: http://localhost:3000
public/index.html
URL を Ctrl+Click すると回転する React ロゴマークとともに
Welcome to React!
が表示される。
React + TypeScript 環境を整える
もう一度 wsl のシェルに戻り、Ctrl+C で bun の Dev サーバーを止めて
$ code .
を実行する。初回だけなんやかんやダウンロードされた後 Windows 側で WSL に接続された状態の VS Code が立ち上がる。
WSL にリモート接続するとローカルの VS Code とは別に拡張機能をインストールすることができる。普段は別の言語での作業が多い場合にはとても便利だ。
ということで TypeScript を名前に冠した拡張機能をいくつか入れておく。
さて、どうやら bun の React のテンプレートには TypeScript がないみたい。
とは言え、含まれている "src/App.jsx" と "src/index.jsx" を "*.tsx" に改名して、"public/index.html" の中の
<script src="/src/index.jsx" async type="module"></script>
こちらも .tsx
に書き換えたら (もしや不要? .jsx
のままでも .js
でも動く)、それだけで TypeScript 化の作業は完了。
Ctrl+J
で VS Code 上の Terminal を開き、再度
$ bun dev
を実行するだけで、全く同じページが表示された。
(キャッシュが表示されているだけでは?と疑うなら tsx の中の XML を編集してみる)
HID を開く
どのご家庭にもある Sony 製の PlaysStation4 用コントローラー DualShock4 (以下 "DS4") を使い、角速度センサー情報を可視化したいと思う。
まず WebHID の TypeScript 型定義ファイルが既に存在するとのことで、依存する node モジュールにそちらを追加。
$ bun add -d @types/w3c-web-hid
とりあえずはテンプレートの App
コンポーネントの中にボタンを追加する。React のコンポーネントが何なのかは並行して学ぶことにする。
このボタンを押すと HID 選択ダイアログが表示され、選択したデバイスからの Input Report が取得できるようになる。
function App() {
return (
<div className="App">
//// 中略
<button onClick={openDevice}>
Open HID
</button>
</div>
);
}
イベントハンドラーは以下のようにした。
async function openDevice() {
const devices: HIDDevice[] = await navigator.hid.requestDevice({
filters: [{
vendorId: 0x54c,
productId: 0x9cc,
},{
vendorId: 0x54c,
productId: 0x5c4,
}]
});
if (devices.length != 0) {
const d = devices[0];
await d.open();
d.addEventListener("inputreport", (e)=>{console.log(e);});
}
}
bun dev
して表示された "Open HID" ボタンを押すと
ポップアップが表示されるので DS4 に該当するデバイスを選んで「接続」すると、
コンソールに Input Report が押し寄せる事になる。
(参考 : TypescriptでWebHIDを扱う)
DualShock4 の Input Report のデコード
このあたり を参考に以下のような DS4 の Input Report をデコードするための class を書いた。
読み飛ばし可。
export class Ds4Buttons<T> {
public north: T;
public east: T;
public south: T;
public west: T;
public square: T;
public cross: T;
public circle: T;
public triangle: T;
public l1: T;
public r1: T;
public l2: T;
public r2: T;
public pause: T;
public option: T;
public l3: T;
public r3: T;
public ps: T;
public tpad: T;
}
export class Ds4Point2D<T> {
public x: T;
public y: T;
}
export class Ds4Finger {
public down: boolean;
public track_num: number;
public coord: Ds4Point2D<number>;
}
export class Ds4Point3D<T> {
public x: T;
public y: T;
public z: T;
}
export class Ds4Input {
public report_id: number;
public stick_left: Ds4Point2D<number>; // 0(left, top)..+256(right, bottom)
public stick_right: Ds4Point2D<number>; // 0(left, top)..+256(right, bottom)
public buttons: Ds4Buttons<boolean>;
public counter: number;
public trigger_left: number; // 0..+256
public trigger_right: number; // 0..+256
public gyro: Ds4Point3D<number>; // (x, y, z)
public accel: Ds4Point3D<number>; // (x, y, z) positive: right, up, backward
public finger1: Ds4Finger;
public finger2: Ds4Finger;
private gen_buttons(b0: number, b1: number, b2: number): Ds4Buttons<boolean> {
let n = false;
let e = false;
let s = false;
let w = false;
switch (b0 & 0xF) {
case 0: { n = true; break;}
case 1: { n = true; e = true; break;}
case 2: { e = true; break;}
case 3: { e = true; s = true; break;}
case 4: { s = true; break;}
case 5: { s = true; w = true; break;}
case 6: { w = true; break;}
case 7: { w = true; n = true; break;}
default: break;
}
let b = new Ds4Buttons<boolean>();
b.north = n;
b.east = e;
b.south = s;
b.west = w;
b.square = (b0 & 0x10) != 0;
b.cross = (b0 & 0x20) != 0;
b.circle = (b0 & 0x40) != 0;
b.triangle = (b0 & 0x80) != 0;
b.l1 = (b1 & 0x01) != 0;
b.r1 = (b1 & 0x02) != 0;
b.l2 = (b1 & 0x04) != 0;
b.r2 = (b1 & 0x08) != 0;
b.pause = (b1 & 0x10) != 0;
b.option = (b1 & 0x20) != 0;
b.l3 = (b1 & 0x40) != 0;
b.r3 = (b1 & 0x80) != 0;
b.ps = (b2 & 0x01) != 0;
b.tpad = (b2 & 0x02) != 0;
return b;
}
private gen_finger(b0: number, b1: number, b2: number, b3: number): Ds4Finger {
let f = new Ds4Finger();
f.down = (b0 & 0x80) != 0;
f.track_num = (b0 & 0x7f);
f.coord = {
x: b1 | ((b2 & 0xF) << 8),
y: (b2 >> 4) | (b3 << 4)
};
return f;
}
public constructor(data: DataView) {
data.getUint8(0)
this.report_id = data.getUint8(0);
this.stick_left = { x: data.getUint8(2), y: data.getUint8(3)};
this.stick_right = { x: data.getUint8(4), y: data.getUint8(5)};
this.buttons = this.gen_buttons(data.getUint8(6), data.getUint8(7), data.getUint8(8));
this.counter = data.getUint8(8) >> 2;
this.trigger_left = data.getUint8(9);
this.trigger_right = data.getUint8(10);
this.gyro = {
x: (data.getUint8(14))|((data.getUint8(15)) << 8),
y: (data.getUint8(16))|((data.getUint8(17)) << 8),
z: (data.getUint8(18))|((data.getUint8(19)) << 8)};
this.accel = {
x: (data.getUint8(20))|((data.getUint8(21)) << 8),
y: (data.getUint8(22))|((data.getUint8(23)) << 8),
z: (data.getUint8(24))|((data.getUint8(25)) << 8)};
this.finger1 = this.gen_finger(data.getUint8(36), data.getUint8(37), data.getUint8(39), data.getUint8(40));
this.finger2 = this.gen_finger(data.getUint8(41), data.getUint8(42), data.getUint8(43), data.getUint8(44));
}
}
さきほどの HIDInputReportEvent
のハンドラ関数を書き直すことで角速度(と加速度)センサーからの出力値をコンソールに表示できるようになった。
function onHidInputReport(e: HIDInputReportEvent) {
let ds4in = new Ds4Input(e.data);
const gyro = ds4in.gyro;
const accel = ds4in.accel;
console.debug(`gyro: {x: ${gyro.x}, y: ${gyro.y}, z: ${gyro.z}}`);
console.debug(`accel: {x: ${accel.x}, y: ${accel.y}, z: ${accel.z}}`);
}
ということで HID Input Report から必要な情報は取り出せるようになった。
HID レポートを可視化する
グラフ表示ライブラリの選定
DS4 からは毎秒だいたい 230 くらいの HID Input Report が送られてくるみたい (実測にて、仕様値は知らない)。
ということでこれを余すことなくグラフにプロットするとなると、それなりに早いライブラリが求められる。
まず目についたのは uPlot。他のライブラリと比べ高速であることを強くアピールしている。
でもそのパフォーマンスはあくまで「大量の静的データをいかに早く表示するか」が指標になっており、彼ら自身も素直に
However, if you need 60fps performance with massive streaming datasets, uPlot can only get you so far.
と述べ、より良い選択肢になるであろう WebGL を採用しているライブラリとして以下を参考例に挙げている。
今回は React のサンプルが用意されていた "webgl-plot" でやってみることにする。
(ところで 3つ目はどう見ても C++ のライブラリなんだけども…)
プロット処理の実装
bun add webgl-plot
で webgl-plot をインストールする。
そして、先程の React サンプルをベースにこんなコンポーネント Chart
を作った。
import React, { useEffect, useRef } from "react";
import { WebglPlot, WebglLine, ColorRGBA } from "webgl-plot";
let webglp: WebglPlot;
let lines: WebglLine[] = [];
export class PlotDataPoint {
x: number;
y: number;
z: number;
}
export class PlotData {
pts: PlotDataPoint[];
}
export class Props {
data: PlotData;
size: number;
}
export default function Chart({data, size}: Props) {
const canvasMain = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasMain.current) {
const devicePixelRatio = window.devicePixelRatio || 1;
canvasMain.current.width =
canvasMain.current.clientWidth * devicePixelRatio;
canvasMain.current.height =
canvasMain.current.clientHeight * devicePixelRatio;
webglp = new WebglPlot(canvasMain.current);
}
}, []);
useEffect(() => {
if (canvasMain.current) {
webglp.clear();
lines.push(new WebglLine(new ColorRGBA(1, 0, 0, 1), size));
lines.push(new WebglLine(new ColorRGBA(0, 1, 0, 1), size));
lines.push(new WebglLine(new ColorRGBA(0, 0, 1, 1), size));
lines.forEach(l => {
webglp.addLine(l);
l.arrangeX();
});
}
}, [size]);
useEffect(() => {
let id = 0;
let renderPlot = () => {
let d = data.pts;
let ofs = size - d.length;
if (lines.length >= 3) {
let l0 = lines[0];
let l1 = lines[1];
let l2 = lines[2];
d.map((pt, i) => l0.setY(i + ofs, pt.x));
d.map((pt, i) => l1.setY(i + ofs, pt.y));
d.map((pt, i) => l2.setY(i + ofs, pt.z));
}
id = requestAnimationFrame(renderPlot);
webglp.update();
};
id = requestAnimationFrame(renderPlot);
return () => {
renderPlot = () => {};
cancelAnimationFrame(id);
};
}, [data, size]);
const canvasStyle = {
width: "90%",
height: "200px"
};
return (
<div>
<canvas style={canvasStyle} ref={canvasMain} />
</div>
);
}
これで、canvas 要素の中に3つのデータ系列 (x, y, z
) がプロットされるようになる。
App
では onHidInputReport
イベントハンドラ関数の中で PlotDataPoint
を整形し配列に詰めていき、一定数を超えたら古いものから捨てるようにした。
App
全体は以下のようになった。
import React from 'react';
import logo from './logo.svg';
import { Ds4Input } from './ds4/ds4';
import Chart, {PlotData, PlotDataPoint} from './Chart'
import './App.css';
function App() {
let plotData = new PlotData();
let plotDataSize = 400;
plotData.pts = [];
function onHidInputReport(e: HIDInputReportEvent) {
let ds4in = new Ds4Input(e.data);
const gyro = ds4in.gyro;
// const accel = ds4in.accel;
let pt = new PlotDataPoint();
const convGyro = (raw:number) :number =>
((raw & 0x8000) != 0) ?
(raw - 0x10000) / 32768.0:
raw / 32768.0;
pt.x = convGyro(gyro.x)
pt.y = convGyro(gyro.y);
pt.z = convGyro(gyro.z);
plotData.pts.push(pt);
if (plotData.pts.length > plotDataSize) {
plotData.pts.shift();
}
console.debug(`plotData has ${plotData.pts.length} data points`)
}
async function openDevice() {
const devices: HIDDevice[] = await navigator.hid.requestDevice({
filters: [{
vendorId: 0x54c,
productId: 0x9cc,
},{
vendorId: 0x54c,
productId: 0x5c4,
}]
});
if (devices.length != 0) {
const d = devices[0];
await d.open();
d.addEventListener("inputreport", onHidInputReport);
}
}
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
<button onClick={openDevice}>
Open HID
</button>
<Chart data={plotData} size={plotDataSize} />
</div>
);
}
export default App;
DS4 を接続 ("Open HID") して、コントローラーをブンブン振ると下図のように gyro
の値が RGB でプロットされるようになった。
速度には概ね満足。ただ、頻繁に表示がカクつく。 GC が走るタイミングなどだろうか?
このあたりはプロットデータの Queue 構造の最適化など考える必要があるだろう。
とりあえず今回はここまで。
Discussion