これは何?
WebGPU-React-Bitmap-Viewport - a React component that uses WebGPU to render bitmap data within a set of viewports synchronously
をつくりました。ビットマップ画像データをビューポート内に表示して、Zoom / Pan / Pinch 操作ができます。
-
ビューポート内のマウスドラッグ操作、マウスホイール操作、ビューポート下端および右端に表示されたスクロールバーでの操作により、ビューポートをインタラクティブに移動・拡大・縮小できます
-
境界を超えてスクロールしようとするとバウンスします
-
ひとつの画像データをもとに、複数のビューポートを連動させて、互いのビューポートの表示範囲を強調した形で表示できます
-
JavaScript的にUint32ArrayやUint8Arrayとして保持している画像データの内容を、Reactコンポーネントとして、任意のキャンバスサイズ・表示範囲でのビューポート内容として表示する目的に特化しています
-
WebGPUを用いることで、ビューポートで表示される必要な最小限の範囲のピクセルだけを抽出し、それに対応するポリゴンを描画するためのインスタンス群を64並列でワークグループにディスパッチするという実装になっています。そのため、極めて高速に、滑らかに動作をします
-
1ピクセルは、拡大時には8角形のタイルとして、縮小時には正方形のタイルとして描画します(このあたりは、コードを書き換えれば自由に変えられるだろうと思います)
-
画像データの内容は、1ピクセルあたりRGBA値の32ビット値をUint32Arrayとして格納したビットマップデータ、RGBやHSVなどのいずれかの8ビット値をUint8Arrayとして格納したビットマップデータなど、表示内容に応じて変えることができます
-
フォーカスされているセル、選択されているセルの状態を内部的に管理し、マウス操作に応じてフォーカスされているセル位置の変化、選択されているセル範囲の変化に応じたコールバック関数をコンポーネントへのプロパティで与えることができます
-
表示する内容が動的に変化した場合に応じて、随時、再描画を行わせることができます
ライブデモ
このコンポーネントを用いて開発したデモを、GitHub Pagesで公開しています。
こちらから試せます。次のような3種類の内容です。
- URLから取得した静的な画像をビューポート表示するデモ
- JavaScriptで生成した動的な画像をビューポート表示するデモ
- シミュレーションプログラム(シェリングの分居モデル)のWebGPU Compute Shaderによる実行内容をアニメーション表示するデモ
とくに、3つめについては、以下ツイートを参照してください。
最新のコードでは、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
つかいかた
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は、タイル状の地図データの表示コンポーネントです。
これに対して本実装では、画像データのサイズは、WebGPUでストレージとして保持できるの最大サイズにまで対応し、それ以上のものは扱わないこととしています。
VRAMに載らないくらいの巨大なサイズの画像データを拡大縮小しながら表示したい場合には、MapLibre GL JSを使って、事前にラスタータイル化したものを表示するのがよいのではないかと思います。
react-zoom-pan-pinch
react-zoom-pan-pinch は、Reactコンポーネントをビューポート内で表示するものです。
これに対して本実装では、ビットマップ画像をZoom / Pan / Pinchしながら表示することに特化しています。
ReactコンポーネントをZoom / Pan / Pinchしながら表示するためにはreact-zoom-pan-pinchを使うのがよいでしょう。
おわりに
まだまだ荒削りな実装ですが、高速・滑らかな操作が実現できたので、よかったです。
(バウンスの動作にまだちょっと違和感がありますが...)
React+WebGPUでのプログラミング、たいへんですけど、たのしいです!