React+WebGPUで2次元データをビューポート内でタイル状に可視化するコンポーネントを作った

# これは何？

![hokusai_demo_movie.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193148/f2bdf4ff-be74-e4fd-8b5c-51f2df6275fa.gif)

WebGPU-React-Bitmap-Viewport - a React component that uses WebGPU to render bitmap data within a set of viewports synchronously


https://github.com/kubohiroya/webgpu-react-bitmap-viewport

をつくりました。ビットマップ画像データをビューポート内に表示して、Zoom / Pan / Pinch 操作ができます。

* ビューポート内のマウスドラッグ操作、マウスホイール操作、ビューポート下端および右端に表示されたスクロールバーでの操作により、ビューポートをインタラクティブに移動・拡大・縮小できます
* 境界を超えてスクロールしようとするとバウンスします
* ひとつの画像データをもとに、複数のビューポートを連動させて、互いのビューポートの表示範囲を強調した形で表示できます

* JavaScript的にUint32ArrayやUint8Arrayとして保持している画像データの内容を、Reactコンポーネントとして、任意のキャンバスサイズ・表示範囲でのビューポート内容として表示する目的に特化しています
* WebGPUを用いることで、ビューポートで表示される必要な最小限の範囲のピクセルだけを抽出し、それに対応するポリゴンを描画するためのインスタンス群を64並列でワークグループにディスパッチするという実装になっています。そのため、極めて高速に、滑らかに動作をします
* 1ピクセルは、拡大時には8角形のタイルとして、縮小時には正方形のタイルとして描画します（このあたりは、コードを書き換えれば自由に変えられるだろうと思います）
* 画像データの内容は、1ピクセルあたりRGBA値の32ビット値をUint32Arrayとして格納したビットマップデータ、RGBやHSVなどのいずれかの8ビット値をUint8Arrayとして格納したビットマップデータなど、表示内容に応じて変えることができます
* フォーカスされているセル、選択されているセルの状態を内部的に管理し、マウス操作に応じてフォーカスされているセル位置の変化、選択されているセル範囲の変化に応じたコールバック関数をコンポーネントへのプロパティで与えることができます
* 表示する内容が動的に変化した場合に応じて、随時、再描画を行わせることができます


# ライブデモ

このコンポーネントを用いて開発したデモを、GitHub Pagesで公開しています。

https://kubohiroya.github.io/webgpu-react-bitmap-viewport/examples/index.html

こちらから試せます。次のような3種類の内容です。

* URLから取得した静的な画像をビューポート表示するデモ
* JavaScriptで生成した動的な画像をビューポート表示するデモ
* シミュレーションプログラム(シェリングの分居モデル)のWebGPU Compute Shaderによる実行内容をアニメーション表示するデモ

とくに、3つめについては、以下ツイートを参照してください。

https://x.com/kubohiroya/status/1849700198509171072

最新のコードでは、MacBook Pro 16インチ 2021(Apple M1 Max 64GB)で動作させたところ、
1024x1024のサイズの画像の動的な表示であれば80fps程度、256x256のサイズであればMacBookProのProMotionの上限のフレームレートである120fpsのパフォーマンスが出ています。

# ダウンロードとインストール

こちらのデモを手元で動かしてみたい場合は、次のように実行してください。

```
git clone https://github.com/kubohiroya/webgpu-react-bitmap-viewport.git
cd webgpu-react-bitmap-viewport
pnpm install
cd examples
pnpm install
vite dev
```

NPMパッケージとして公開しているので、自分のアプリに組み込みたい場合には、次のように実行してください。
```
pnpm add webgpu-react-bitmap-viewport
```

# つかいかた
```TypeScript
import { Grid, GridHandles } from "webgpu-react-bitmap-viewport";
import { useRef } from "react";

// 表示するビットマップ画像データの大きさ
const gridSize = { numColumns: 128, numRows: 128 };

// 表示するビットマップ画像データをランダムな内容で作成
const data =  new Float32Array(gridSize.numRows * gridSize.numColumns);
for (let i = 0; i < data.length; i++) {
  if (Math.random() < 0.99) {
    data[i] = i / data.length;
  } else {
    data[i] = Infinity;
  }
}

// フォーカス位置を保持する配列
const focusedCellPosition = new Uint32Array([-1, -1]);

// セルの選択状態を保持する配列
const selectedStates = new Uint32Array(Math.ceil(gridSize.numRows * gridSize.numColumns / 32));

// ビューポートの表示範囲を保持する配列
const viewportStates = new Float32Array([
  0.0, 0.0, 16.0, 16.0, // viewport index 0: left, top, right, bottom
  8.0, 8.0, 24.0, 24.0, // viewport index 1: left, top, right, bottom
]);

export const App = () => {

  // 連動する2つのビューポート(Grid)へのハンドラ
  const gridHandlerRefs =
    [
      useRef<GridHandles>(null),
      useRef<GridHandles>(null)
    ];

  return (
    <>
      <Grid
        index={0}
        ref={gridHandlerRefs[0]}
        numViewports={2}
        headerOffset={{ left: 10, top: 10 }}
        canvasSize={{ width: 300, height: 300 }}
        numColumns={gridSize.numColumns}
        numRows={gridSize.numRows}
        scrollBar={{
          radius: 5.0,
          margin: 2.0,
        }}
        data={data}
        focusedCellPosition={focusedCellPosition}
        selectedStates={selectedStates}
        viewportStates={viewportStates}
        onFocusedCellPositionChange={(sourceIndex: number, columnIndex: number, rowIndex: number) => {
          gridHandlerRefs[1].current?.refreshFocusedCellPosition(sourceIndex, columnIndex, rowIndex);
        }}
        onSelectedStateChange={(sourceIndex:number, columnIndex: number, rowIndex: number) => {
          gridHandlerRefs[1].current?.refreshSelectedState(sourceIndex, columnIndex, rowIndex);
        }}
        onViewportStateChange={(sourceIndex: number) => {
          gridHandlerRefs[1].current?.refreshViewportState(sourceIndex);
        }}
      />
      <Grid
        index={1}
        ref={gridHandlerRefs[1]}
        numViewports={2}
        headerOffset={{ left: 10, top: 10 }}
        canvasSize={{ width: 300, height: 300 }}
        numColumns={gridSize.numColumns}
        numRows={gridSize.numRows}
        scrollBar={{
          radius: 5.0,
          margin: 2.0,
        }}
        data={data}
        focusedCellPosition={focusedCellPosition}
        selectedStates={selectedStates}
        viewportStates={viewportStates}
        onFocusedCellPositionChange={(sourceIndex: number, columnIndex: number, rowIndex: number) => {
          gridHandlerRefs[0].current?.refreshFocusedCellPosition(sourceIndex, columnIndex, rowIndex);
        }}
        onSelectedStateChange={(sourceIndex: number, columnIndex: number, rowIndex: number) => {
          gridHandlerRefs[0].current?.refreshSelectedState(sourceIndex, columnIndex, rowIndex);
        }}
        onViewportStateChange={(sourceIndex: number) => {
          gridHandlerRefs[0].current?.refreshViewportState(sourceIndex);
        }}
      />
    </>
  );
}
```

# 類似のライブラリとの比較

他のライブラリでも、似たようなものがあるかと思います。




## MapLibre GL JS

[MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js)は、タイル状の地図データの表示コンポーネントです。

https://github.com/maplibre/maplibre-gl-js


これに対して本実装では、画像データのサイズは、WebGPUでストレージとして保持できるの最大サイズにまで対応し、それ以上のものは扱わないこととしています。
VRAMに載らないくらいの巨大なサイズの画像データを拡大縮小しながら表示したい場合には、MapLibre GL JSを使って、事前にラスタータイル化したものを表示するのがよいのではないかと思います。

## react-zoom-pan-pinch


[react-zoom-pan-pinch](https://github.com/BetterTyped/react-zoom-pan-pinch) は、Reactコンポーネントをビューポート内で表示するものです。

https://github.com/BetterTyped/react-zoom-pan-pinch

これに対して本実装では、ビットマップ画像をZoom / Pan / Pinchしながら表示することに特化しています。
ReactコンポーネントをZoom / Pan / Pinchしながら表示するためにはreact-zoom-pan-pinchを使うのがよいでしょう。
 
# おわりに

まだまだ荒削りな実装ですが、高速・滑らかな操作が実現できたので、よかったです。
(バウンスの動作にまだちょっと違和感がありますが...)

React+WebGPUでのプログラミング、たいへんですけど、たのしいです！