テクスチャアニメーション
React要素をオフスクリーンで1枚のSkImageに焼き込むテクスチャの仕組みと、useTexture・useImageAsTexture・usePictureAsTextureの使い分けを解説します。
ここまでのアニメーションは、<Circle>や<Path>のpropsを毎フレーム再計算するものでした。図形がシンプルなうちは問題になりませんが、複雑な描画(グラデーション・複数のシェイプの組み合わせ・テキストなど)を毎フレーム丸ごと再計算するのはコストが高くなります。
そこで使うのがテクスチャ(texture)です。React要素を一度だけオフスクリーンでラスタライズして1枚のSkImageにしておき、それをGPU上のテクスチャとして使い回します。以降は「テクスチャそのものを動かす」だけで済むので、中身の再計算が要りません。
useTexture: React要素をテクスチャにする
useTextureは、React要素と出力サイズを渡すと、その描画結果を共有値(Shared Value)として返すフックです。
import { useTexture, Image, Canvas, Fill } from "@shopify/react-native-skia";
const Demo = () => {
const texture = useTexture(<Fill color="cyan" />, { width: 256, height: 256 });
return (
<Canvas style={{ flex: 1 }}>
<Image image={texture} x={0} y={0} width={256} height={256} />
</Canvas>
);
};
戻り値のtextureは共有値なので、前々章で見た通り<Image>のimagepropにそのまま渡せます。
次のデモでは、星形のパスを一度だけテクスチャに焼き込み、回転・拡大縮小させています。星のパス自体はアニメーション中に一切再計算されていません。
本来なら<Group transform={transform}><Image .../></Group>とuseDerivedValueだけで、前章の<Circle cx={cx} cy={cy}>と同じように書けるはずです。ただしこのプロジェクトのWeb版レンダラーには、<Group>のtransformで包んだ<Image>の再描画が反映されないという既知の制約があります(詳しくは前章の「注意点」を参照)。そのためこのデモでは、requestAnimationFrameで毎フレームSkPictureを作り直す方式を使っています。
const StarTexture = () => <Path path={starPath} color="#f5c451" />;
const recorder = Skia.PictureRecorder();
const TextureSpinDemo = () => {
const texture = useTexture(<StarTexture />, { width: 96, height: 96 });
const clock = useClock();
const [picture, setPicture] = useState<SkPicture | null>(null);
useEffect(() => {
let frame: number;
const tick = () => {
if (texture.value) {
const t = clock.value / 1000;
const scale = 0.8 + Math.sin(t * 2) * 0.15;
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, 240, 240));
canvas.save();
canvas.translate(120, 120);
canvas.rotate((t * 180) / Math.PI, 0, 0);
canvas.scale(scale, scale);
canvas.drawImage(texture.value, -48, -48);
canvas.restore();
setPicture(recorder.finishRecordingAsPicture());
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [clock, texture]);
return (
<Canvas style={{ flex: 1 }}>
{picture && <Picture picture={picture} />}
</Canvas>
);
};
実機・ネイティブでは、この回避策は不要です。<Group transform={transform}><Image image={texture} .../></Group>とuseDerivedValueをそのまま使ってください。
useImageAsTexture・usePictureAsTexture
useTexture以外にも、テクスチャを作る手段が2つ用意されています。
| フック | 入力 | 用途 |
|---|---|---|
useTexture |
React要素 | JSXで組んだ描画内容をそのままテクスチャに |
useImageAsTexture |
画像ソース(requireなど) |
画像ファイルをGPUにアップロードしてテクスチャに |
usePictureAsTexture |
SkPicture |
命令的なAPIやReactツリーの外で作った描画命令をテクスチャに |
// 画像ファイルをそのままテクスチャにする
const texture = useImageAsTexture(require("./assets/image.png"));
// SkPictureからテクスチャを作る
const rec = Skia.PictureRecorder();
const canvas = rec.beginRecording();
canvas.drawColor(Skia.Color("cyan"));
const picture = rec.finishRecordingAsPicture();
const texture = usePictureAsTexture(picture, { width: 200, height: 200 });
いずれも戻り値は共有値で、<Image>にそのまま渡せる点は共通です。下のデモは、useTexture・useImageAsTexture・usePictureAsTextureの3つを並べ、それぞれ異なる入力(React要素・画像ファイル・SkPicture)から同じように<Image>で表示できることを示しています。
内部の仕組み: runOnUI
これらのフックは内部で、ReanimatedのrunOnUIを使ってUIスレッド上にオフスクリーンサーフェスを作り、そこに描画してからテクスチャとして取り出しています。
const createTexture = (image) => {
"worklet";
const surface = Skia.Surface.MakeOffscreen(200, 200)!;
const canvas = surface.getCanvas();
canvas.drawColor(Skia.Color("cyan"));
surface.flush();
image.value = surface.makeImageSnapshot();
};
const image = useSharedValue(null);
useEffect(() => {
runOnUI(createTexture)(image);
}, []);
useTextureなどの高レベルフックを使っていれば、このあたりを自分で書く必要はほとんどありません。ただし「テクスチャがどこで作られているか」を理解しておくと、<Atlas>と組み合わせて大量のスプライトを動かす(第2部の該当章を参照)ような、より高度な使い方にも応用が効きます。
注意点
テクスチャは「一度作ったら描画内容が変わらないもの」に向いています。中身が毎フレーム変わるような描画には不向きで、その場合は素直に通常のコンポーネントとして描いたほうがシンプルです。パーティクルやスプライトのように、同じ絵を大量に、位置や角度だけ変えて繰り返し描く場面でこそ効果を発揮します。
公式ドキュメントのTexturesも参照してください。