2025-03-20

React Native SkiaのAtlasについて

SkiaのAtlasについて

一言で言うとAtlasは一つの画像から大量のオブジェクトをまとめて効率的に描画する機能です。

2DグラフィックライブラリのReact Native SkiaのドキュメントにAtlas というページが追加されていました。ライブラリが出来た当初は無かった気がするので、後から追加された機能と思われます。これがどのようなものなのか調べてみました。

ひとまず今回学んだ内容を活かした成果物を貼っております。ドラッグすると、このタピオカのような円が動きます。このように、大量に同じ形状のオブジェクトを描画する場合に力を発揮します。

Loading Skia..

そもそもAtlasとは

全般的にAtlasとはそもそも何でしょうか?

Atlasという言葉が使われている実例でいうと、ゲームエンジンのUnityではスプライトアトラス(SpriteAtlas)という機能があります。これは複数のテクスチャ(画像素材)を1枚のテクスチャに統合してくれる機能です。画像を一つにまとめておくことによりテクスチャの読み込みを減らすことができるため、パフォーマンスの向上に繋がります。

また2Dイラストを立体的に動かすツールであるLive2Dでは、テクスチャアトラス(Texture Atlas) という機能があります。これも、部品となる画像を配置しまとめてくれる機能のようです。

これらは固有の名称ですが、全般的にAtlasという言葉は"複数の画像を1枚の画像に統合する"、または"統合した画像をソースとして複数の要素を描画する"という意味合いで使われているようです。英単語だと"地図帳"を意味する単語のため、そこから来ている思われます。

ドキュメントのサンプルコードの実行

HelloWorld

ではまずドキュメントのサンプルコード(Hello World)をこのページ上で実際に実行してみます。

Loading Skia..

上記は、以下のコードをWeb上で実行したものです。Webでも問題なく動作しているのがわかります。

const size = { width: 25, height: 11.25 }
const strokeWidth = 2
const imageSize = {
  width: size.width + strokeWidth,
  height: size.height + strokeWidth,
}
const image = drawAsImage(
  <Group>
    <Rect
      rect={rect(strokeWidth / 2, strokeWidth / 2, size.width, size.height)}
      color="cyan"
    />
    <Rect
      rect={rect(strokeWidth / 2, strokeWidth / 2, size.width, size.height)}
      color="blue"
      style="stroke"
      strokeWidth={strokeWidth}
    />
  </Group>,
  imageSize,
)

export default function AtlasHelloWorld() {
  const numberOfBoxes = 200
  const pos = { x: 128, y: 128 }
  const width = 256
  const sprites = new Array(numberOfBoxes)
    .fill(0)
    .map(() => rect(0, 0, imageSize.width, imageSize.height))
  const transforms = new Array(numberOfBoxes).fill(0).map((_, i) => {
    const tx = 5 + ((i * size.width) % width)
    const ty = 25 + Math.floor(i / (width / size.width)) * size.width
    const r = Math.atan2(pos.y - ty, pos.x - tx)
    return Skia.RSXform(Math.cos(r), Math.sin(r), tx, ty)
  })

  return (
    <Canvas style={{ width: 256, height: 256 }}>
      <Atlas image={image} sprites={sprites} transforms={transforms} />
    </Canvas>
  )
}

最初の部分ではdrawAsImageで矩形をAtlas用の画像として生成しています。drawAsImageはSkiaのエレメントをSkImageとして生成してくれます。

const image = drawAsImage(
  <Group>
    ...
  </Group>,
  imageSize,
)

JSXのCanvas内でこの画像を元にAtlasコンポーネントを使用して描画しています。

<Atlas image={image} sprites={sprites} transforms={transforms} />

spritesはAtlas画像からどの領域を利用するかを指定する配列です。このサンプルではAtlas画像全体を利用するため、すべてのスプライトで画像全体の領域が指定されています。

const sprites = new Array(numberOfBoxes)
  .fill(0)
  .map(() => rect(0, 0, imageSize.width, imageSize.height))

transformsはスプライトの配置位置と回転角度を指定するものです。配列サイズはspritesと同じになる必要があります。この例では矩形の中心点に向かって回転しながら配置されるよう、Skia.RSXformの配列を生成しています。RSXformには回転行列を cons, sin, 配置位置x, 配置位置yの順で指定します。

const transforms = new Array(numberOfBoxes).fill(0).map((_, i) => {
  // 矩形の配置位置をiを元に計算
  const tx = 5 + ((i * size.width) % width)
  const ty = 25 + Math.floor(i / (width / size.width)) * size.width
  // 矩形と中心点posとの向き(角度)を計算
  const r = Math.atan2(pos.y - ty, pos.x - tx)
  // 回転行列を作成
  return Skia.RSXform(
    Math.cos(r),  // 角度を元にしたx方向の向き
    Math.sin(r),  // 角度を元にしたy方向の向き
    tx,
    ty
  )
})

Skia.RSXformの引数はcossinでなく単純な角度を指定すれば直感的でわかりやすいと思ってしまうのですが、角度を渡すとそこから内部で都度conssinを計算しないといけなくなり、その分計算負荷がかかることになってしまうためだと思います。また、この形式であればscale(後述)も反映させることができます。

HelloWorld スケール変更

試しにtransformsにスケールを反映させてみます。0.7を適用してみます。

Loading Skia..

それぞれの矩形のサイズが小さくなっていることがわかります。transformsの生成の際に、cossinに対してそれぞれscaleを掛けることでスケールを反映させることができます。

const scale = 0.7
const transforms = new Array(numberOfBoxes).fill(0).map((_, i) => {
  const tx = 5 + ((i * size.width) % width)
  const ty = 25 + Math.floor(i / (width / size.width)) * size.width
  const r = Math.atan2(pos.y - ty, pos.x - tx)
  const scos = Math.cos(r) * scale
  const ssin = Math.sin(r) * scale
  return Skia.RSXform(scos, ssin, tx, ty)
})

アニメーションサンプル

次にドキュメントに乗っているアニメーションのサンプルを実行してみます。ドラッグすると中心点が変化します。

Loading Skia..

コードは以下です。

const pos = useSharedValue({ x: 0, y: 0 })
const texture = useTexture(
  <Group>
    <Rect
      rect={rect(strokeWidth / 2, strokeWidth / 2, size.width, size.height)}
      color="cyan"
    />
    <Rect
      rect={rect(strokeWidth / 2, strokeWidth / 2, size.width, size.height)}
      color="blue"
      style="stroke"
      strokeWidth={strokeWidth}
    />
  </Group>,
  textureSize,
)
const gesture = Gesture.Pan().onChange(e => (pos.value = e))
const numberOfBoxes = 150
const width = 256
const sprites = new Array(numberOfBoxes)
  .fill(0)
  .map(() => rect(0, 0, textureSize.width, textureSize.height))

const transforms = useRSXformBuffer(numberOfBoxes, (val, i) => {
  "worklet"
  const tx = 5 + ((i * size.width) % width)
  const ty = 25 + Math.floor(i / (width / size.width)) * size.width
  const r = Math.atan2(pos.value.y - ty, pos.value.x - tx)
  val.set(Math.cos(r), Math.sin(r), tx, ty)
})

return (
  <GestureDetector gesture={gesture}>
    <Canvas style={{ width: 256, height: 256 }}>
      <Atlas image={texture} sprites={sprites} transforms={transforms} />
    </Canvas>
  </GestureDetector>
)

Reanimatedと連携してアニメーションを実現していることがわかります。

Atlas画像を生成する箇所でuseTextureが使われています。

const texture = useTexture(
  <Group>
    ...
  </Group>,
  textureSize,
)

HelloWorldのサンプルではdrawAsImageが使われていましたが、この違いはなんでしょうか?

ドキュメントには以下のように記述がありuseTextureを使うとテクスチャをUIスレッド上で直接生成できるとあります。

First, the useTexture hook will enable you to create a texture on the UI thread directly without needing to make any copies.

drawAsImageだとJSスレッド上で実行されますが、ReanimatedのSharedValueを元に見た目を変化させる処理はUIスレッド上で行われます。そのため、都度テクスチャデータをコピーする処理が発生しパフォーマンスに問題が出てしまうということだと思います。useTextureであればUIスレッド上でテクスチャを生成してくれるため、コピーするオーバーヘッドが発生しません。

transformsの生成にはuseRSXformBufferを使っています。

const transforms = useRSXformBuffer(numberOfBoxes, (val, i) => {
  "worklet"
  const tx = 5 + ((i * size.width) % width)
  const ty = 25 + Math.floor(i / (width / size.width)) * size.width
  const r = Math.atan2(pos.value.y - ty, pos.value.x - tx)
  val.set(Math.cos(r), Math.sin(r), tx, ty)
})

useRSXformBufferは以下のように説明がありますが、

Secondly, we provide you with hooks such as useRectBuffer and useRSXformBuffer to efficiently animates on the sprites and transformations.

Creates an array for rotate scale transforms to be animated. Can be used by any component that takes an array of rotate scale transforms as property, like the Atlas API.

useRSXformBufferを使うことで各スプライトに対しての位置移動・回転・スケール等の変化を設定することができます。2つ目の引数のコールバック関数の引数valの型はSkRSXformで、HelloWorldの時にSkia.RSXform()で生成していた値と同等のものです。ここでアニメーションのSharedValueであるposの値を反映することができます。また、このコールバック関数をUIスレッド上で実行されるため、"worklet"の記述が必要です。

タピオカAtlasアニメーション

今回得た知識を元にAtlasを使ったアニメーションを自作してみました。タピオカのような小さなボールをAtlasで多数描画しています。特定の箇所をドラッグ(クリック)すると、ボールが動きます。

Loading Skia..

コードはこちらを参照してください。

タップしたエリアの近くの円が動く部分は、タップ位置をSharedValueで保存しuseRSXformBufferのコールバック関数内で、対象スプライトがその座標の近くにいた場合移動しています。

また、今回カラーを4色用意したかったため、4つ分useTextureを使ってテクスチャ画像を生成しています。そのため<Atlas />コンポーネントも4つレンダリングしています。transformsの設定では、位置や角度は変更させられますが、カラーについては変更させられないためです。別途ReanimatedのSharedValueでカラーを渡して外から変更させれば一つの<Atlas />コンポーネントでも複数カラーは実現可能な気はしますがそれは試していません。

また、テクスチャ画像を4つ生成するのではなく、1つのテクスチャに4つ対象を配置しておきそこから切り取る形であればテクスチャは一つで済みそうです。ただ、この形式を取ると統合された画像内の各テクスチャがどの位置にあるのかを把握しないといけなくなるため、その分管理の手間が増えそうです。大量の種類のテクスチャをまとめて扱うような場合や、Atlas画像を外部から生成して渡したりするようなケースでは有効な気がします。

Atlasの機能はあくまでアトラス画像から領域を切り取って描画するというもので、そのオブジェクトの性質そのものを変化させてくれるわけでないので、その特徴を念頭においておくと自身がしたい表現を実現するのにAtlasを使うべきかどうかの判断がしやすそうです。

Atlasを使っている実例

最後にAtlasを使っている実例となるReact Nativeのライブラリを上げておきます。

最終更新: 2025-03-30 03:31
筆者: @gaishimo 主にReact Nativeでのアプリ開発を専門に行っています。 React Nativeのお仕事お受けいたしますのでお気軽にご相談ください。
© 2025 Omoidasu, Inc. All rights reserved.