一言で言うとAtlasは一つの画像から大量のオブジェクトをまとめて効率的に描画する機能です。
2DグラフィックライブラリのReact Native SkiaのドキュメントにAtlas というページが追加されていました。ライブラリが出来た当初は無かった気がするので、後から追加された機能と思われます。これがどのようなものなのか調べてみました。
ひとまず今回学んだ内容を活かした成果物を貼っております。ドラッグすると、このタピオカのような円が動きます。このように、大量に同じ形状のオブジェクトを描画する場合に力を発揮します。
全般的にAtlasとはそもそも何でしょうか?
Atlasという言葉が使われている実例でいうと、ゲームエンジンのUnityではスプライトアトラス(SpriteAtlas)という機能があります。これは複数のテクスチャ(画像素材)を1枚のテクスチャに統合してくれる機能です。画像を一つにまとめておくことによりテクスチャの読み込みを減らすことができるため、パフォーマンスの向上に繋がります。
また2Dイラストを立体的に動かすツールであるLive2Dでは、テクスチャアトラス(Texture Atlas) という機能があります。これも、部品となる画像を配置しまとめてくれる機能のようです。
これらは固有の名称ですが、全般的にAtlasという言葉は"複数の画像を1枚の画像に統合する"、または"統合した画像をソースとして複数の要素を描画する"という意味合いで使われているようです。英単語だと"地図帳"を意味する単語のため、そこから来ている思われます。
ではまずドキュメントのサンプルコード(Hello World)をこのページ上で実際に実行してみます。
上記は、以下のコードを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
の引数はcos
、sin
でなく単純な角度を指定すれば直感的でわかりやすいと思ってしまうのですが、角度を渡すとそこから内部で都度cons
とsin
を計算しないといけなくなり、その分計算負荷がかかることになってしまうためだと思います。また、この形式であればscale(後述)も反映させることができます。
試しにtransforms
にスケールを反映させてみます。0.7を適用してみます。
それぞれの矩形のサイズが小さくなっていることがわかります。transforms
の生成の際に、cos
とsin
に対してそれぞれ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)
})
次にドキュメントに乗っているアニメーションのサンプルを実行してみます。ドラッグすると中心点が変化します。
コードは以下です。
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で多数描画しています。特定の箇所をドラッグ(クリック)すると、ボールが動きます。
コードはこちらを参照してください。
タップしたエリアの近くの円が動く部分は、タップ位置をSharedValue
で保存しuseRSXformBuffer
のコールバック関数内で、対象スプライトがその座標の近くにいた場合移動しています。
また、今回カラーを4色用意したかったため、4つ分useTexture
を使ってテクスチャ画像を生成しています。そのため<Atlas />
コンポーネントも4つレンダリングしています。transforms
の設定では、位置や角度は変更させられますが、カラーについては変更させられないためです。別途ReanimatedのSharedValue
でカラーを渡して外から変更させれば一つの<Atlas />
コンポーネントでも複数カラーは実現可能な気はしますがそれは試していません。
また、テクスチャ画像を4つ生成するのではなく、1つのテクスチャに4つ対象を配置しておきそこから切り取る形であればテクスチャは一つで済みそうです。ただ、この形式を取ると統合された画像内の各テクスチャがどの位置にあるのかを把握しないといけなくなるため、その分管理の手間が増えそうです。大量の種類のテクスチャをまとめて扱うような場合や、Atlas画像を外部から生成して渡したりするようなケースでは有効な気がします。
Atlasの機能はあくまでアトラス画像から領域を切り取って描画するというもので、そのオブジェクトの性質そのものを変化させてくれるわけでないので、その特徴を念頭においておくと自身がしたい表現を実現するのにAtlasを使うべきかどうかの判断がしやすそうです。
最後にAtlasを使っている実例となるReact Nativeのライブラリを上げておきます。