Headlessレンダリングとオフスクリーン活用
React Native SkiaをNode上で動かし、画面に出さずに画像を生成するHeadlessレンダリング。オフスクリーンサーフェスへの描画とスナップショット取得の仕組みを解説します。
同じ描画の考え方を、画面上のアニメーションだけでなく動画・画像生成・Webへ展開できます。
React Native Skiaはオフスクリーン描画に対応しているため、Canvasをアプリの画面に表示することなく、Node.js上だけで動かせます。SkiaのAPIを使って図形を描き、その結果を画像としてエンコード・保存するといった使い方ができます。既定ではCPUで描画されますが、GPUアクセラレーションを使うことも可能です。
サムネイル生成、OGP画像の自動生成、バッチ処理でのグラフ描画など、UIとして表示しない画像生成のバックエンド処理に向いています。
Node上でのHello World
Node上でReact Native Skiaを使う場合、importの書き方が通常のReact Nativeコードと異なります。Node環境にはWebpackのようなモジュールバンドラーがないため、CommonJS版のビルドを直接指定する必要があります。また、React Native固有のAPIに依存しないSkiaのAPIだけをまとめたheadlessというエントリーポイントが用意されています。
import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/commonjs/web/LoadSkiaWeb";
import {
Circle,
drawOffscreen,
getSkiaExports,
Group,
makeOffscreenSurface,
} from "@shopify/react-native-skia/lib/commonjs/headless";
(async () => {
const width = 256;
const height = 256;
const size = 60;
const r = size * 0.33;
await LoadSkiaWeb();
// CanvasKitがロードされたあとは、getSkiaExports()でSkia本体にアクセスできる
const { Skia } = getSkiaExports();
using surface = makeOffscreenSurface(width, height);
using image = await drawOffscreen(
surface,
<Group blendMode="multiply">
<Circle cx={r} cy={r} r={r} color="cyan" />
<Circle cx={size - r} cy={r} r={r} color="magenta" />
<Circle cx={size / 2} cy={size - r} r={r} color="yellow" />
</Group>
);
console.log(image.encodeToBase64());
})();
流れを分解すると次の3ステップです。
LoadSkiaWeb()でCanvasKit(WASM)を読み込む(この本の各デモがブラウザで行っているのと同じロード処理です)makeOffscreenSurface(width, height)で画面に紐づかない描画先(サーフェス)を作るdrawOffscreen(surface, element)にJSXで書いた図形ツリーを渡し、描画結果の画像スナップショットを受け取る
drawOffscreenに渡すelementはCircleやGroupなど、普段Canvasの中で使っているコンポーネントとまったく同じものです。Canvasという入れ物なしに、同じ図形の記述からいきなり画像を得られるのがポイントです。
ブラウザでも体験できるコアの仕組み
makeOffscreenSurface・drawOffscreenが使っているサーフェスとスナップショットのAPI自体は、Node専用の機能ではなくSkia(CanvasKit)本体の機能です。そのため、この本のデモのようにブラウザ上でCanvasKitが動いている環境でも同じように呼び出せます。以下のデモは、Canvas要素を画面に一切置かず、オフスクリーンで描いた3色の円をPNGとしてエンコードし、<Image>として表示しています。
実際のHeadlessレンダリングの用途(バッチ処理でのファイル書き出しなど)はNode上でのCLI実行が前提になるためブラウザ上のデモでは再現できませんが、「サーフェスに描いてスナップショットを取る」という核の部分は上のデモで体験できる通りです。
GPUアクセラレーション
Node上でのオフスクリーン描画は、既定ではCPUで行われます。GPUを使いたい場合はOffscreenCanvas APIのポリフィルをNode側に用意する必要があります。headless-glを使ったWebGLベースのポリフィル実装例が、React Native Skiaのメンテナーによって公開されています。
大量の画像を継続的に生成するようなワークロードでは、CPU描画がボトルネックになりやすいので、GPUアクセラレーションの導入を検討する価値があります。
主なAPI
| 名前 | 説明 |
|---|---|
LoadSkiaWeb() |
CanvasKit(WASM)を読み込む。Web版と共通の関数 |
getSkiaExports() |
{ Skia }を返す。ロード後にSkiaのAPI全体へアクセスする窓口 |
makeOffscreenSurface(width, height) |
オフスクリーンの描画先サーフェスを作成 |
drawOffscreen(surface, element) |
JSXの図形ツリーをサーフェスに描画し、SkImageのスナップショットを返す(非同期) |
surface.flush() |
描画コマンドをGPU/CPUに送出する(drawOffscreen内部で呼ばれる) |
image.encodeToBase64() / encodeToBytes() |
画像をBase64文字列またはバイト列にエンコード |
注意点
headlessエントリーポイントはReact Native本体への依存を持たないSkiaのAPIだけを公開しているため、ViewやStyleSheetなど通常のReact Nativeコンポーネントは使えません。Circle・Group・PathのようなSkiaの図形コンポーネントに限定して図形ツリーを組み立てます。
makeOffscreenSurface・drawOffscreenが返すサーフェスや画像はネイティブリソース(WASM側のメモリ)を保持しているため、使い終わったら破棄するのが望ましい動作です。上のコード例にあるusing宣言はJavaScriptの明示的リソース管理(Explicit Resource Management)で、スコープを抜けるタイミングで自動的に破棄してくれます。手動で破棄する場合はsurface.dispose()・image.dispose()を呼び出します。
公式ドキュメントのHeadlessも参照してください。