Operations2026.08.30 · 5 min read

dev client のビルドが1周10分なので、SkSL shader は canvaskit-wasm で先にコンパイルした

認証基盤・クラウドインフラ

Expo と VisionCamera v5 の SkiaCamera で、カメラの映像をドット絵にする SkSL shader を書いた。ネイティブモジュールなので Expo Go では動かず、確かめるには dev client のビルドが要る。それが1周約10分で、初回は原因不明で失敗した。この周期で shader のコンパイルエラーを潰すのは無理なので、実機の前で潰せるものを Node 上で潰してからビルドに進んだ。

shader を1行直すと、実機で見るまで約10分かかる

ドット絵カメラの PoC は、Expo 57 に react-native-vision-camera と react-native-vision-camera-skia を足した構成になっている。SkiaCamera の onFrame はフレームを SkImage として渡してくるので、それを RuntimeEffect の child shader にして drawRect すれば、shader 側でモザイク化と減色ができる。

問題は確認のコストだった。dev client はビルド用の Mac で eas build --local を回して作る。パイプライン全体で約10分。しかも初回は Gradle が落ちた。

eas build --local(初回)
[RUN_GRADLEW] BUILD FAILED in 9m 57s
Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.

同じコミットをもう一度回したら成功した。原因は分かっていない。つまり「shader を直して実機で見る」を素朴にやると、1周10分で、しかもその周が shader と無関係な理由で無駄になることがある。SkSL のタイプミスをこの周期で見つけるのは、やる前から無理だと分かる。

同じ Skia の SkSL コンパイラが npm にある

React Native Skia の Skia.RuntimeEffect.Make がやっているのは Skia の SkSL コンパイルで、それは Skia を WebAssembly にした canvaskit-wasm の RuntimeEffect.Make と同じコンパイラだ。実機でしか動かないのは VisionCamera とその GPU 経路であって、shader の文法検査ではない。ならば shader 文字列だけを Node で canvaskit-wasm に食わせれば、コンパイルエラーは実機の前で取れる。

そのために1つ手を入れた。shader 文字列は最初 import { Skia } from '@shopify/react-native-skia' と同じファイルにあり、Node からは読めなかった。文字列だけを src/pixelShaderSource.ts に分離し(依存はパレット色数の定数1つ)、Skia で Make する側を src/pixelShader.ts に残した。検査スクリプトは数行で済む。

check.mts
import CanvasKitInit from 'canvaskit-wasm'
import { PIXEL_SHADER_SOURCE } from '<project>/src/pixelShaderSource'

const ck = await CanvasKitInit()
const effect = ck.RuntimeEffect.Make(PIXEL_SHADER_SOURCE, (msg) => {
  console.error('SKSL ERROR:', msg)
})
console.log(
  effect
    ? `SKSL_OK uniforms=${effect.getUniformCount()} floats=${effect.getUniformFloatCount()}`
    : 'SKSL_FAIL',
)

最初は check.ts で書いて、tsx に Top-level await is currently not supported with the "cjs" output format と言われた。.mts にリネームして通した。

結果
$ npx tsx check.mts
SKSL_OK uniforms=5 floats=52

コンパイルが通ったことより、floats=52 のほうが役に立った。shader の uniform は float が4つ(pixelSize / levels / dither / paletteSize)と float3 palette[16] で、4 + 16 × 3 = 52。実機側で makeShaderWithChildren に渡す配列の長さがこれと一致していなければ、shader が正しくても描画は壊れる。Skia 自身に数えさせた 52 は、その配列を組む側の検算になった。

実機でしか動かない部分と、実機でなくても検証できる部分は別物だ。ライブラリの下にあるコンパイラが npm にもあるなら、そこまでは開発機で終わらせられる。

worklet に変換されたかは、バンドルを grep して確かめる

もう1つ、ビルド前に潰しておきたい不安があった。onFrame は 'worklet' ディレクティブ付きの関数で、Babel の react-native-worklets/plugin が変換しないとカメラのスレッドで動かない。このプロジェクトには babel.config.js が無く、babel-preset-expo が自動で入れてくれるのかが分からなかった。preset のソースを grep しても worklets: platformOptions.worklets, というオプションがあることしか分からない。

ソースを読んで推測するより、出力を見るほうが確実だった。expo export でバンドルを出し、自分の onFrame だけが呼ぶ関数名で引く。既定の出力は bytecode 化と minify がかかって関数名で追えないので、両方を切る。

expo export の出力を関数名で引く
$ npx expo export --platform ios --no-bytecode --no-minify --output-dir <dir>
$ grep -n "makeShaderWithChildren" <dir>/_expo/static/js/ios/*.js
108733:    code: "function PixelCameraTsx1(frame,render){const{dots,levels,dither,paletteSize,paletteColors,TileMode,FilterMode,MipmapMode,effect,Skia}=this.__closure;render(function({frameTexture:frameTexture,canvas:canvas}){...});frame.dispose();}"

this.__closure に dots / levels / effect / Skia が入っている。関数の外側で参照している値がクロージャとして切り出されているということで、これが worklet 変換の痕跡だ。plugin は既に効いていて、babel.config.js を足す必要はなかった。

「念のため babel.config.js を作って plugin を明示する」案は捨てた。効いているものを二重に登録する形になるうえ、効いているかどうかをバンドルで見れば済む話に、設定ファイルを1枚増やす理由がない。

実機は SkSL より手前で落ちた

ここまでを済ませて dev client を Android 実機に入れた。結果、shader には到達しなかった。Metro に同じスタックが毎フレーム出続けた。

Metro
ERROR  Failed to render! Error: Frame.getNativeBuffer(...): java.lang.RuntimeException: HardwareBuffers require minSdk 26 or higher!

VisionCamera のネイティブ側で、フレームを AHardwareBuffer にする関数が __ANDROID_API__ で分岐していた。

投げている側
// react-native-vision-camera/android/src/main/cpp/NativeBufferHelper.cpp
#if __ANDROID_API__ >= 26
  AHardwareBuffer* hardwareBuffer = AHardwareBuffer_fromHardwareBuffer(...);
  ...
#else
  throw std::runtime_error("HardwareBuffers require minSdk 26 or higher!");
#endif

これはコンパイル時の分岐で、端末の Android バージョンは関係ない。ビルド時の minSdkVersion が 26 未満だと throw する側がバイナリに入る。Expo SDK 57 の既定は 24 で、VisionCamera の build.gradle はアプリ側の値をそのまま使う。SkiaCamera は毎フレーム getNativeBuffer を呼ぶので、Skia 経路はこのままでは1フレームも描けない。expo-build-properties で minSdkVersion を 26 に上げた。ネイティブの変更なので、dev client の再ビルドがもう1周要る。

正直に書くと、canvaskit-wasm での検査もバンドルの grep もどちらも正しかったが、この失敗はどちらにも見つけられない種類だった。片方は SkSL の文法、もう片方は JavaScript の変換を見ていて、ネイティブのコンパイル時分岐はその外側にある。再ビルド後に shader が動くか、フレームレートがどうかは、この時点ではまだ分かっていない。

まとめ

1周が10分の検証ループでは、そのループに乗せるものを選ばないといけない。SkSL のコンパイルと worklet の変換は、どちらもライブラリが実機で使っているのと同じ道具(Skia のコンパイラ、Babel の plugin)が開発機にあり、実機に乗せる前に決着がつく。それを実機側に残すと、10分の周が shader のタイプミスや設定の不安で消費される。

同時に、開発機で潰せるものを全部潰しても、実機で初めて出る失敗は残る。今回はそれがネイティブの #if だった。手前の検査の価値は、実機に持ち込む未知を減らして、残った失敗を「shader のせいではない」と切り分けられる状態にしておくことにある。

ExpoVisionCameraReact Native SkiaSkSLcanvaskit-wasm