【2022/12/12 追記】2D Texture Arrayの取得について、GLSLを使用しない方法を追記しました。
【2024/5/16 追記】補足の一部(Point Sptite MATのアスペクト比に関する記載)を修正しました。
はじめに
@minoge1001と申します。昨年のアドカレではParticle SOPについて書きましたが、今年もParticle SOPネタでいこうと思います。
先日、弊社で開発しているARシステムのプレスリリースが出ました。本システムでは、Macbookでカメラ入力から諸情報をリアルタイムに解析し、その結果をNDIのメタデータに乗せてTouchDesignerで受信・CGを描画しています。どういった演出が可能かは下のデモ映像を観て頂ければと思いますが、この中の多くのシーンでParticle SOPと2Dアニメーションの組み合わせを使っています。
さて、Particle SOPで生成された数十~数百個のパーティクルに2Dアニメーションを貼り付けるにはどうすればよいでしょうか?最も単純な方法は、アニメーションを読み込んだMovie File In TOPをそのままPoint Sprite MATのColor Mapに割り当てることですが、そうすると全てのパーティクルで同じ再生タイミングのアニメーションとなってしまうため、(演出的に意図したものでない限り)コレじゃない感満載の残念な感じになってしまいます。

また、Texture Instancingを用いる手法も考えられますが、この場合も事前に準備したInstance Texturesのパターン数でしか再生タイミングをずらせないため、バリエーションを持たせようとすると限界があります。かといって、必要な数だけMovie File In TOPをReplicatorで複製して再生タイミングをずらすような方法は、明らかに筋の良くない解決策です(試していませんが、処理的に厳しいでしょう)。
そこで、本システムではアニメーションの全フレームを2D Texture Arrayに保存し、各パーティクルは自分の現在の年齢(=発生してから消滅するまでの期間の中でどの位置にいるのか)に応じて参照するテクスチャを変えるという手法で実装しています。この手法ですと、アニメーションは一箇所のVRAMに事前に保存しておくだけなので、再生タイミングの異なる同一アニメーションをスマートに実装できます。
では、実際の作り方を解説していきます。
2D Texture Arrayの作成
2Dアニメーションの読み込み
パーティクルに貼り付けたいアニメーションを準備します。今回の例題ではTELOPICTさんの爆発アニメーションをお借りしました。こちらのページからアルファありのmov形式をダウンロードしてください。また、本素材は29.97fpsなので、分かりやすくするために画面左下のプロジェクトのFPSも29.97にしておきます。

ダウンロードしたアニメーションをMovie File In TOPで読み込み、中クリック(もしくは右クリック>Info...)のLengthでフレーム数を確認しておきます(この場合は30)。また、一旦Play ModeをSpecify Indexとし、Index = 0と指定してアニメーションの先頭フレームを確認しておきます。確認後、Play Mode = Sequentialに戻します。

次に、Resolution TOP(もしくはfit TOP)を繋いで解像度を下げます。演出に必要となる最低限の解像度まで下げておくことで、後段のTexture 3D TOPで消費されるVRAMの量を節約できます。今回はOutput Resolution = Quarter(=250×250)としました。

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の解説ページをご覧ください。ざっくり言うと、3D Textureは隣り合うテクスチャ間のブレンディングが可能である一方、2D Texture Arrayはできません(その名の通り、2次元テクスチャの配列です)。今回はアニメーションのフレームをブレンドする必要はないので、2D Texture Arrayを使います。Texture 3D TOPのビューワーの左下がVRAMに保存された1枚目のフレーム(w = 0)、右上が最後のフレーム(w = 29)となります。最後に、Pre Fill = Onにして現在の入力を保存します。Offのままでも見た目は変わりませんが、裏では常に書き換えが走っているので処理的にはOnにしてCookを切っておく方がベターです。

さて、ここで大半の方は「Texture 3D TOPの先頭フレーム(左下)」と「先ほど確認したアニメーションの先頭フレーム」が一致していないと思います。これは、Movie File In TOPでの2DアニメーションのスタートタイミングとTexture 3D TOPの保存開始タイミングがずれているためです。以降の処理では、ここが一致している必要がありますので、どうにかして合わせます。今回の記事では
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です。

次にVectorsタブでUniform変数を設定します。uOffsetは後ほど手動で調整するオフセット値で、一旦0にしておきます。また、uDepthは2D Texture Arrayの深さ、つまりアニメーションのフレーム数(30)です。ここでは、me.inputs[0].depthという式を入力することで、当該ノードの1番目の入力(inputs[0])のdepthを取得しています。

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成分に上書きすることで、オフセット値だけずれたフレームのピクセル値を取得することができます。
剰余計算は%演算子でも可能ですが、被除数が負になった際の挙動が怪しかったため、mod関数を利用しました。このページの話が関係しているかもしれません。
この状態でVectorsタブのuOffsetの値を動かすとフレーム全体がずらせるので、先ほど確認した先頭フレームがビューワーの左下にくるように調整します。この例では+11だけ動かしています。

【追記】アニメーションの表示フレームと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というチャンネルが出来ます。
アニメーションは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への保存が完了します。

これで、2D Texture Arrayの準備が出来ました。
パーティクルの設定
パーティクルの送出点の準備
Particle SOPの準備をします。今回の例ではランダムな順序で球状に爆発アニメーションを散らしたいため、Sphere SOP・Point SOP・Sort SOPを下図のように繋げました。今回はパーティクルは球の表面から動かさないので、Point SOPで法線を削除(No Normal)としています。

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秒)とします。
アニメーションフレーム数(30Fr)とプロジェクトのFPS設定(29.97Fr/秒)より、実時間での尺は30 / 29.97(秒)で計算できます。
ここで、SOP to DATでParticle SOPの中身を見てみると、life(0)がパーティクルが生まれてからの経過時間、life(1)が先ほど設定したパーティクルの寿命を示していることが分かります。つまり、life(0)をlife(1)で割ることで、パーティクルが発生してから消滅するまでの経過時間を0~1の値に正規化することができます(本記事ではこの値をパーティクルの年齢と呼んでいます)。この正規化はパーティクル表現を行う際にはよく使う手法なので、覚えておいて損はありません!

パーティクルの年齢に応じたフレームの割り当て
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まで整数値でカウントアップされている様子が確認できます。

レンダリング
作成した2D Texture Array(GLSL TOP)をPoint Sprite MATのColor Mapに割り当てます。このままではテクスチャの表示サイズが小さすぎて何も見えませんが、Point SpriteタブのConstant Point Scaleを100程度に上げると見えるようになります。また、CommonタブのDiscard Pixels Based on AlphaをOnにすることで、テクスチャが重なった場合でもアニメーションの透過部分がうまく表示されるようになります。

最後に、このMATを用いて先ほどのパーティクル(2番目のPoint SOP)をレンダリングすれば完成です。

全体のネットワークは以下の通りです。
-
GLSL TOPを使用した場合
-
Timer CHOPを使用した場合
まとめ
本記事では、多数のパーティクルに同一の2Dアニメーションを非同期の再生タイミングで複製する手法について解説しました。今回はパーティクルが生まれてから消滅するまでの期間の全てでアニメーションを貼り付けましたが、パーティクルの寿命とテクスチャ座標(w)の持たせ方を工夫することで、パーティクルの年齢の任意のタイミングにアニメーションを付けることも可能です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 別記事で解説します。
-
その際には、アニメーションの先頭フレームや最終フレームに空フレームを追加する下処理が必要となる場合があります。 ↩