【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秒)とします。
アニメーションフレーム数(30
Fr)とプロジェクトのFPS設定(29.97
Fr/秒)より、実時間での尺は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 別記事で解説します。
-
その際には、アニメーションの先頭フレームや最終フレームに空フレームを追加する下処理が必要となる場合があります。 ↩