Particle SOP × 2D Animation

**【2022/12/12 追記】[2D Texture Arrayの取得](#2d-texture-arrayの取得)について、GLSLを使用しない方法を追記しました。**

**【2024/5/16 追記】[補足](#補足)の一部（`Point Sptite MAT`のアスペクト比に関する記載）を修正しました。**

***

# はじめに
[@minoge1001](https://twitter.com/minoge1001)と申します。[昨年のアドカレ](https://qiita.com/minoge1001/items/802e81388a9397a29688)ではParticle SOPについて書きましたが、今年もParticle SOPネタでいこうと思います。

先日、[弊社で開発しているARシステム](https://sep.co.jp/news/6209/)のプレスリリースが出ました。本システムでは、Macbookでカメラ入力から諸情報をリアルタイムに解析し、その結果をNDIのメタデータに乗せてTouchDesignerで受信・CGを描画しています。どういった演出が可能かは下のデモ映像を観て頂ければと思いますが、この中の多くのシーンでParticle SOPと2Dアニメーションの組み合わせを使っています。
<br>

<iframe width ="100%" height ="400" src="https://www.youtube.com/embed/o1w7MoyzAdw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

***

さて、Particle SOPで生成された数十～数百個のパーティクルに2Dアニメーションを貼り付けるにはどうすればよいでしょうか？最も単純な方法は、アニメーションを読み込んだ`Movie File In TOP`をそのまま`Point Sprite MAT`の`Color Map`に割り当てることですが、そうすると**全てのパーティクルで同じ再生タイミングのアニメーションとなってしまう**ため、（演出的に意図したものでない限り）コレじゃない感満載の残念な感じになってしまいます。
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/0824e368-136d-79d4-3ea1-8f0f8d3a46ff.gif">
また、Texture Instancingを用いる手法も考えられますが、この場合も**事前に準備したInstance Texturesのパターン数でしか再生タイミングをずらせない**ため、バリエーションを持たせようとすると限界があります。かといって、必要な数だけ`Movie File In TOP`をReplicatorで複製して再生タイミングをずらすような方法は、明らかに筋の良くない解決策です（試していませんが、処理的に厳しいでしょう）。

そこで、本システムでは**アニメーションの全フレームを2D Texture Arrayに保存し、各パーティクルは自分の現在の年齢（＝発生してから消滅するまでの期間の中でどの位置にいるのか）に応じて参照するテクスチャを変える**という手法で実装しています。この手法ですと、アニメーションは一箇所のVRAMに事前に保存しておくだけなので、再生タイミングの異なる同一アニメーションをスマートに実装できます。

では、実際の作り方を解説していきます。

# 2D Texture Arrayの作成
## 2Dアニメーションの読み込み
パーティクルに貼り付けたいアニメーションを準備します。今回の例題では[TELOPICT](https://telopict.com/)さんの爆発アニメーションをお借りしました。[こちらのページ](https://telopict.com/sousyoku_sozai_b027/)から**アルファありのmov形式**をダウンロードしてください。また、本素材は29.97fpsなので、分かりやすくするために画面左下の**プロジェクトのFPSも29.97**にしておきます。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/084e6fca-f6b8-ff62-85f2-622c48d8bc60.png">
ダウンロードしたアニメーションを`Movie File In TOP`で読み込み、中クリック（もしくは右クリック>`Info...`）の`Length`でフレーム数を確認しておきます（この場合は**30**）。また、一旦`Play Mode`を`Specify Index`とし、`Index = 0`と指定してアニメーションの先頭フレームを確認しておきます。確認後、`Play Mode = Sequential`に戻します。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/7c061dad-15dc-cc53-9b8b-0b55ac8ce2d2.png">
次に、`Resolution TOP`（もしくは`fit TOP`）を繋いで解像度を下げます。演出に必要となる最低限の解像度まで下げておくことで、後段の`Texture 3D TOP`で消費されるVRAMの量を節約できます。今回は`Output Resolution = Quarter`（=250×250）としました。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/d8cd7c66-081c-265a-4c96-39528bb75d66.png">

## 2D Texture Arrayの取得
`Resolution TOP`に`Texture 3D TOP`を繋ぎ、`Type = 2D Texture Array`とします。また、`Cache Size`をアニメーションのフレーム数と同じく`30`とします。ここで、3D Textureとは**通常のテクスチャ座標（u、v）に加えて、深さ方向のパラメータ（w）を持つテクスチャ**です。`3D Texture`と`2D Texture Array`の違いは[Derivativeの解説ページ](https://derivative.ca/UserGuide/3D_Texture)をご覧ください。ざっくり言うと、**3D Textureは隣り合うテクスチャ間のブレンディングが可能である一方、2D Texture Arrayはできません（その名の通り、2次元テクスチャの配列です）**。今回はアニメーションのフレームをブレンドする必要はないので、`2D Texture Array`を使います。`Texture 3D TOP`のビューワーの左下がVRAMに保存された1枚目のフレーム（`w = 0`）、右上が最後のフレーム（`w = 29`）となります。最後に、`Pre Fill = On`にして現在の入力を保存します。`Off`のままでも見た目は変わりませんが、裏では常に書き換えが走っているので処理的には`On`にしてCookを切っておく方がベターです。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/54d0f882-ad8e-f42a-973e-e4da2a5ab170.png">
さて、ここで大半の方は「`Texture 3D TOP`の先頭フレーム（左下）」と「先ほど確認したアニメーションの先頭フレーム」が一致していないと思います。これは、`Movie File In TOP`での2Dアニメーションのスタートタイミングと`Texture 3D TOP`の保存開始タイミングがずれているためです。以降の処理では、ここが一致している必要がありますので、どうにかして合わせます。今回の記事では

1. [**GLSL TOPを用いて後から手動で調整する方法**](#glsl-topを用いて後から手動で調整する方法)
2. [**【追記】アニメーションの表示フレームとTexture 3D TOP上の保存位置を一致させる方法**](#追記アニメーションの表示フレームとtexture-3d-top上の保存位置を一致させる方法)
の2つを方法を紹介します。

### GLSL TOPを用いて後から手動で調整する方法
`Texture 3D TOP`に`GLSL TOP`を接続します。`GLSL TOP`ではCompute Shaderを用いてw方向にオフセット値を加えます。まず最初に、`GLSL`タブの`Mode`を`Compute Shader`にします。また、`Auto Dispatch Size = ON`、`Outout Type = 2D Texture Array`とします。これで下準備はOKです。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/4e47a064-397a-104f-bd89-dac17b7a2470.png">
次に`Vectors`タブでUniform変数を設定します。`uOffset`は後ほど手動で調整するオフセット値で、一旦`0`にしておきます。また、`uDepth`は2D Texture Arrayの深さ、つまりアニメーションのフレーム数（`30`）です。ここでは、`me.inputs[0].depth`という式を入力することで、当該ノードの1番目の入力（`inputs[0]`）の`depth`を取得しています。
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/4b1a806f-6b9e-3f3e-1ebe-081fa95d8110.png">
`glsl1_compute`に書くコードは以下のようになります。
```glsl:glsl1_compute
uniform int uOffset;
uniform int uDepth;

layout (local_size_x = 8, local_size_y = 8) in;
void main()
{
	int depth = int( mod( int(gl_GlobalInvocationID.z) + uOffset, uDepth ) );
	vec4 color = texelFetch(sTD2DArrayInputs[0], ivec3(gl_GlobalInvocationID.xy, depth), 0);
	imageStore(mTDComputeOutputs[0], ivec3(gl_GlobalInvocationID.xyz), TDOutputSwizzle(color));
}
```

ここで、`depth`は**2D Texture Arrayのどの深さのテクスチャをサンプリングするか**を決めるint型の変数です。元々の深さ方向のサンプリング位置（`gl_GlobalInvocationID.z`）はuint型なので、それを一旦int型にキャストした後、オフセット値（`uOffset`）を加えて、全体のテクスチャ枚数（`uDepth`）で割った余りを求めています。ここで得られた`depth`を`texelFetch`関数の第2引数（どの点をサンプリングするかを指定するivec3型の変数）のz成分に上書きすることで、オフセット値だけずれたフレームのピクセル値を取得することができます。

:::note info
剰余計算は`%`演算子でも可能ですが、被除数が負になった際の挙動が怪しかったため、`mod`関数を利用しました。[このページ](https://shunirr.hatenablog.jp/entry/20120409/1333993409)の話が関係しているかもしれません。
:::

この状態で`Vectors`タブの`uOffset`の値を動かすとフレーム全体がずらせるので、先ほど確認した**先頭フレームがビューワーの左下にくるように調整**します。この例では`+11`だけ動かしています。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/c4ef9912-fffa-c347-ca07-3c62628f0bb9.png">

### 【追記】アニメーションの表示フレームとTexture 3D TOP上の保存位置を一致させる方法
アニメーションのフレーム値（`Index`）と`Texture 3D TOP`での保存位置をCHOPベースで合わせることで、後からGLSLで手動調整しなくても一発で保存できる方法を見つけました。まず、`Timer CHOP`を準備し、`Timer`タブの`Length`を`29`、単位を`F`（Frames）とします。また、`Outputs`タブの`Timer Count`を`Frames`に変更します。こうすることで、`Timer CHOP`内に0から29までカウントアップする`timer_frames`というチャンネルが出来ます。

:::note info
アニメーションは30Frなので、フレームを指すIndexの値は**0 ～ 29**となります。
:::

次に、`Movie File In TOP`の`Play Mode`を`Specify Index`とし、`Index`は先ほど作成した`timer_frames`というチャンネルを参照するよう設定します（`op('timer1')['timer_frames']`）。こうすることで、**`Movie File In TOP`は`timer_frames`が指すIndexのフレームを表示するようになります**。

また、`Texture 3D TOP`は`Type = 2D Texture Array`・`Cache Size = 30`に変更した上で、`Active = Off`にします。それから、`Replace Single = On`・`Replace Index = op('timer1')['timer_frames']`とします。これは、**`Texture 3D TOP`上で`timer_frames`の示すIndexの画像が現在の入力に置き換える（Replace Single）ことを意味します**。

つまり、`timer_frames = N`の時、`Movie File In TOP`は`Index = N`のフレームを表示し、`Texture 3D TOP`は`Index = N`の位置のフレームをそれに置き換える、という動作となります。**これをN=0～29まで繰り返すことで、アニメーションのフレームのIndexと`2D Texture Array`のIndexは全て一致します**。先ほどの状態から`Timer CHOP`の`Timer`タブで`Init`→`Start`の順に押下すると、`Texture 3D TOP`への保存が完了します。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/cea04d95-a4f7-ce19-8158-8a64256bbafd.png">
これで、2D Texture Arrayの準備が出来ました。

# パーティクルの設定
## パーティクルの送出点の準備
`Particle SOP`の準備をします。今回の例では**ランダムな順序で球状に爆発アニメーションを散らしたい**ため、`Sphere SOP`・`Point SOP`・`Sort SOP`を下図のように繋げました。今回はパーティクルは球の表面から動かさないので、`Point SOP`で法線を削除（`No Normal`）としています。
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/25ec798d-9072-c3db-8a48-6f57fd0539f9.png">

## Particle SOPの設定
次に、`Sort SOP`の後段に`Particle SOP`を繋げます。`State`タブの`Particle Type`を`Render as Point Sprites`とし、`Remove Unused Points = On`にしておきます。また、`Particles`タブで`Life Expect`（パーティクルの寿命、単位は**秒**）をパーティクルの寿命を2Dアニメーションの尺に合わせて`30 / 29.97`（=1.001秒）とします。

:::note info
アニメーションフレーム数（`30`Fr）とプロジェクトのFPS設定（`29.97`Fr/秒）より、実時間での尺は`30 / 29.97`（秒）で計算できます。
:::

ここで、`SOP to DAT`で`Particle SOP`の中身を見てみると、`life(0)`が**パーティクルが生まれてからの経過時間**、`life(1)`が**先ほど設定したパーティクルの寿命**を示していることが分かります。つまり、**life(0)をlife(1)で割ることで、パーティクルが発生してから消滅するまでの経過時間を0～1の値に正規化**することができます（本記事ではこの値をパーティクルの**年齢**と呼んでいます）。**この正規化はパーティクル表現を行う際にはよく使う手法なので、覚えておいて損はありません！**
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/ccf7e8b2-601b-3286-3616-fc12cc978abc.png">

## パーティクルの年齢に応じたフレームの割り当て
`Particle SOP`に`Point SOP`を繋いで、2D Texture Arrayの中からパーティクルの年齢に応じたフレームを割り当てます。具体的には、パーティクルが生まれた直後は1枚目のフレームのテクスチャ（`w = 0`）を、パーティクルが消滅する直前は最後のフレームのテクスチャ（`w = 29`）を、パーティクルが寿命の半分になった時（生成されてから0.5秒後）には真ん中のフレーム（`w = 14`）を参照するよう、各パーティクルのw座標を設定します。

`Point`タブの`Texture`の箇所を`Add Texture`とし、w座標（`mapw`）に`math.floor(29.999 * me.inputPoint.life[0] / me.inputPoint.life[1])`と入力します。`me.inputPoint.life[0] / me.inputPoint.life[1]`の部分で各パーティクルの年齢（0～1）を正規化した後、`29.999`を掛けて`math.floor`で切り下げることでフレーム数（0～29）に変換しています。`SOP to DAT`で中身を見てみると、`uv(2)`（=w）の値が0から29まで整数値でカウントアップされている様子が確認できます。
<img width="75%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/2df97643-9ebf-8112-d825-b845fac08a3f.png">

# レンダリング
作成した2D Texture Array（`GLSL TOP`）を`Point Sprite MAT`の`Color Map`に割り当てます。このままではテクスチャの表示サイズが小さすぎて何も見えませんが、`Point Sprite`タブの`Constant Point Scale`を`100`程度に上げると見えるようになります。また、`Common`タブの`Discard Pixels Based on Alpha`を`On`にすることで、テクスチャが重なった場合でもアニメーションの透過部分がうまく表示されるようになります。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/a266caa5-7187-f3af-c01e-f39981db199a.png">
最後に、このMATを用いて先ほどのパーティクル（2番目の`Point SOP`）をレンダリングすれば完成です。
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/a0f24aa7-2c09-595d-0a67-a57384addab0.gif">
全体のネットワークは以下の通りです。
- `GLSL TOP`を使用した場合
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/11b64890-562d-6e89-a469-30d414d4f8ec.png">

- `Timer CHOP`を使用した場合
<img width="100%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214923/77e26de9-31a3-813e-cd7b-78aebab743ef.png">

# まとめ
本記事では、**多数のパーティクルに同一の2Dアニメーションを非同期の再生タイミングで複製する手法**について解説しました。今回はパーティクルが生まれてから消滅するまでの期間の全てでアニメーションを貼り付けましたが、パーティクルの寿命とテクスチャ座標（w）の持たせ方を工夫することで、パーティクルの年齢の任意のタイミングにアニメーションを付けることも可能です[^1]。また、本手法は事前にアニメーションのコピー数が分からないようなケース（例えば、ユーザーのアクションによって再生すべきアニメーションの数が変動するようなシステム）にも応用できるでしょう。

[^1]: その際には、アニメーションの先頭フレームや最終フレームに空フレームを追加する下処理が必要となる場合があります。

# 補足
本記事では`Point Sprite MAT`を用いて、各パーティクル（Point Sprite）にアニメーションを貼り付けました。この方法には以下の特長があります。

- **ネットワークがシンプル**
- **処理が軽い**
- **テクスチャが常にカメラの方を向いてくれる（デメリットにもなりうる）**

一方で、以下に挙げる制約もあります。

- **~~テクスチャのアスペクト比が強制的に1:1（正方形）になってしまう~~**
    - デフォルトでは正方形になってしまいますが、`Point Sprite MAT`の`Point Sprite`タブから`Offset Left/Right/Bottom/Top`の数値を変更することでアスペクト比を調整できます
- **テクスチャの回転ができない（上下方向が常に同じ）**
- **テクスチャが常にカメラの方を向いてしまう（パースが効かせられない）**

これらの制約は、四角ポリゴンを準備してそこにアニメーションを貼り付けることで克服できます。具体的には、`Rectangle SOP`を`Copy SOP`で複製し、`Copy SOP`の`Rotate to Normal`や`Stamp`機能を利用して個々の四角ポリゴンの角度を調整します。本記事ではそこまで言及できなかったので、機会があれば追記 or 別記事で解説します。