🎮

Bun から始める HID 可視化

2022/11/20に公開

はじめに

目標

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!

Learn React

が表示される。

React + TypeScript 環境を整える

もう一度 wsl のシェルに戻り、Ctrl+C で bun の Dev サーバーを止めて

$ code .

を実行する。初回だけなんやかんやダウンロードされた後 Windows 側で WSL に接続された状態の VS Code が立ち上がる。

これだけでもう気分は勝確

WSL にリモート接続するとローカルの VS Code とは別に拡張機能をインストールすることができる。普段は別の言語での作業が多い場合にはとても便利だ。
ということで TypeScript を名前に冠した拡張機能をいくつか入れておく。

Extensions パネル

さて、どうやら 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 が取得できるようになる。

src/App.tsx
function App() {
  return (
    <div className="App">
//// 中略
      <button onClick={openDevice}>
        Open HID
      </button>
    </div>
  );
}

イベントハンドラーは以下のようにした。

src/App.tsx
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 を書いた。

読み飛ばし可。
src/ds4/ds4.ts

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 のハンドラ関数を書き直すことで角速度(と加速度)センサーからの出力値をコンソールに表示できるようになった。

src/App.tsx
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 を作った。

src/Chart.tsx
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 全体は以下のようになった。

src/App.tsx
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