前回は「プログラムをどのように書いていけばいいのか?」に焦点を当てて解説しました。千里の道も一歩から、千行のプログラムも一行から、作りたいプログラムの内容を分析して、それを徐々に細かいパーツに分解していく感覚を身に付けるためには、自分でプログラムを書いてみるしか方法はありません。この連載を読んで 「なるほどー」と思うだけでは不十分です。ぜひ自分で試してみてください。
さて今回は、前回「Vol.3:スクリプトの具体的な作り方(前編)」 の最後に少しだけ触れた Particle Flow を使用してジオメトリの影を Cube にする方法の解説をします。PF でスクリプト!? と尻ごみをしてしまう人も多いかもしれませんが、基本は通常の MAXScript と同じです。

Particle Flow( PF )入門

そもそも Particle Flow(以下、PF)を触ったことすらない! という方も多いでしょう。そこで、まずは簡単に PF について解説をします。PF はその名の通り、パーティクルの処理の流れをグラフィカルに設定するためのシステムです。近いシステムに Houdini やノードベースで視覚的に各種機能の設定画行える Softimage の ICE、3ds Max のプラグイン thinkingParticles などがありますが、それらよりもシンプルなシステムのため、ノードベースの処理システムが苦手な人でも比較的簡単に使用することができることでしょう。

Particle Flowのプリセットをシーンに配置した例

PF のプリセット(Standard Flow)をシーンに配置した状態。デフォルトの表示では少し判りにくいので、Display をジオメトリにし、立方体を表示するようにしている

PF で処理するパーティクルの一生は以下のような感じです。

<1>生成(Birth)
......Birth オペレータでパーティクルを生成します。この時はパーティクルが生まれただけで、位置やその他属性は特に決まっていません

<2>配置(Position)
......Position オペレータでパーティクルの位置を決めます

<3>初期速度指定(Speed)
......Speed オペレータで、パーティクルの初速を決めます

<4>各種処理
......外部から力を与えたり、衝突判定をさせるなどしてパーティクルの挙動を制御します

<5>表示(Display)
......計算結果を画面に表示します

<6>消滅
......指定した条件に合致したパーティクルは削除します

以降は、計算ステップ毎に<4>〜<6>を繰り返します。そして、イベントの最後まで処理が終わると計算ステップが 1 回終わり、次の段階ではイベントを頭から計算し直します(Birth、Position、Speed などはパーティクルが発生した時にしか影響を与えません)。ここで重要になってくるのが、計算ステップの間隔です。PF では「インテグレーションステップ」と呼びます。

PF のインテグレーション ステップ設定パラメータ

PF のインテグレーション ステップ設定パラメータ

この値は、1 フレームをどれだけ細かく区切って計算するかというものです。当然、この値を細かくすればするほど計算の精度は上がっていきますが、それだけ計算時間も増えます。また、ビューポートとレンダリングで値を変えれば、プレビュー時の結果とレンダリング時の結果も違ってきます。それらをきちんと考慮した上で、この値を決める必要があります。

インテグレーション ステップ計算方式の概念図

インテグレーション ステップ計算方式の概念図。1フレームでは、計算が1回であるのに対して、1/4フレームでは同時間で4回に分けて計算を行うため精度が上がる

より詳しい内容は、Autodesk 3ds Max ヘルプから、[ Autodesk 3ds Max ヘルプ→スペース ワープとパーティクル システム→パーティクル システム→パーティクル フロー→パーティクル フローの操作方法]を参照してください。

[[SplitPage]]

PF を使い、ジオメトリの影を Cube 状に表現する

では、実際に PF でジオメトリの影を Cube 状にしてみましょう。以下のような手順で処理していきます。

<1>パーティクルを発生させる

<2>パーティクルを格子状に配置する

<3>配置したパーティクルのうち、影になっていない部分を削除する

<4>表示

<5>全パーティクルを削除

この、<1>〜<5>を毎フレーム繰り返せば、前回のサンプルムービー(下)のような映像表現が行えるようになります。実際にはもっと効率的だったり、賢いアルゴリズムはたくさんあるのですが、毎回作って消すというのが最もシンプルだったので、今回はこの手法を採用しました。それでは、その手順を具体的に解説していきましょう。
 

これより解説していく、MAXScript で PF を制御したサンプルムービー

前半:パーティクルを格子状に配置

まず最初に、インテグレーションステップを 1 フレームに設定します。今回の方法では、1 フレームに一度だけ計算を行えば良いためです。準備ができたらパーティクルを格子状に配置します。今回は<1>と<2>をまとめて Birth スクリプトオペレータで行いました。



----Birth Script----

  on ChannelsUsed pCont do(
    pCont.useAge = true
    pCont.usePosition = true
  )

  on Init pCont do (
  )

  on Proceed pCont do (
    cubeSize=5
    mapSize = 200
    for i in -mapSize/2 to mapSize/2 by cubeSize do (
      for j in -mapSize/2 to mapSize/2 by cubeSize do (
        pCont.AddParticle()
        pCont.particleIndex = pCont.NumParticles()
        pCont.particleAge = 0
        pCont.particlePosition = [i, j, 0]
      )
    )
  )

  on Release pCont do (
  )

----

ChannelsUsed Processed Release 「ハンドラ」と呼ばれ、中身は関数と同じように定義します。関数と違うのは、それぞれのハンドラは PF のイベントの中で呼ばれるという事です。ChannelsUsed ハンドラは、オペレータ内でどのようなパラメータが使用されるかを PF に知らせます。



  on ChannelsUsed pCont do(
    pCont.useAge = true		← エージ チャンネルを有効にします
    pCont.usePosition = true	← 位置チャンネルを有効にします
  )

これはほとんど、"おまじない"と同じで「こんなものなんだ」程度に軽く覚えてもらえば十分です。そして、次の Processed ハンドラがこのスクリプトオペレータの本体になります。



  on Proceed pCont do (
    distance=5
    mapSize = 200
    for i in -mapSize/2 to mapSize/2 by distance do (
      for j in -mapSize/2 to mapSize/2 by distance do (
        pCont.AddParticle()
        pCont.particleIndex = pCont.NumParticles()
        pCont.particleAge = 0
        pCont.particlePosition = [i, j, 0]
      )
    )
  )

Processed の引数の pCont は PF から渡される、パーティクル情報を管理しているコンテナです。pCont という名前の籠の中に、パーティクルが入っている状態をイメージしてください。

distance や mapSize という変数を決めた後、i と j の二重ループが回ります。ここはもう、慣れた形ですね。このループの中では、i 、j 共に -mapSize/2〜mapSize/2 までの値を順に取って、 pCont.particlePosition = [i, j, 0] でパーティクルの位置として使用しています。つまり、縦横 mapSize の範囲に、間隔 distance 置きにパーティクルを配置しています。それでは、j のループの中をより詳しく見てみましょう。



  pCont.AddParticle()

AddParticle() で、コンテナ pCont にパーティクルを追加します。これが、ステップ 1 のパーティクルの発生です。



  pCont.particleIndex = pCont.NumParticles()

これから操作するパーティクルを指定します。パーティクルは追加された順番に 1、2、3......と、番号が振られていくので直前に追加されたパーティクルを指定する場合は pCont に含まれているパーティクルの数を指定します。



  pCont.particleAge = 0

パーティクルエージを 0 に指定します。この値は、タイムラインが進むにつれて自動的に加算されていきます。



  pCont.particlePosition = [i, j, 0]

その上で、パーティクルの位置を指定します。 i,j は、先ほど説明した通りです。全ループが終わると、200×200 ユニット(デフォルトの単位はインチ)範囲のグリッド状に配置されることになります。この時の状態を表示してみましょう。Send Out をオフにして、Display を Event 001 に追加すると、Birth Script の結果を確認することができます。

Particle Flow:Birst Script の結果を確認

Display(赤マル)を Event 001 に追加すると、Birst Script の結果が確認できる

[[SplitPage]]

後半:影になっていない部分を削除する

先ほどまでは腕慣らしで、ここからが本番です。さて、あるパーティクルがライトから見て、ジオメトリの陰になっているかどうかを判断するにはどうすればよいのでしょうか? この方法は幾つか考えられますが、一番最初に考えられるのはライトマップを生成し、何とかしてパーティクル位置のライトマップの値を取得する方法でしょうか。しかし、これはちょっと考えただけでも何だか面倒くさそうですね。
そこで、ちょっと一工夫します。パーティクルから光源に向かって Ray を飛ばし、Ray がジオメトリと交差するかを判断してみるのです。「Ray を飛ばすって、レイトレースレンダリングするの? 何だか大変そう〜」と思ってしまったかもしれませんが、実は MAXScript には、 Ray とジオメトリの交点を計算する機能があるのです。この機能は覚えておくと、意外な使い道があるんですよ。

Ray 値は、Ray の始点と方向を持ちます。始点を各パーティクルの位置、方向を光源の向きに指定します。位置は直ぐに判りますが、方向はどうやって計算すればいいのでしょうか? ここは、昔々に数学や物理で習った ベクトル を思い出してください。

空間ベクトルの例

(空間)ベクトルの例

上の画像のように、原点を O 、そこから点 A、B に向かうベクトルを各々 A→ B→ とします。その時、B から A へ向かうベクトルは「 A→ - B→ 」になります。ここで A を「光源」、B を「パーティクルの位置」とすれば、光源の位置からパーティクルの位置を引いた値がパーティクルから光源に向かうベクトルとなります。

また、Ray とジオメトリの交点を求めるには「 intersectRayEx 」関数を使用します。 intersectRayEx は引数にジオメトリと Ray を渡すと、レイとジオメトリの交点を返します。ジオメトリと Ray が交差しない場合は undefined を返します。今回は交差するかしないか判定するだけでよいので、undefined かどうかを判断するだけで十分です。処理の内容を分かりやすく図にすると以下のようになります。

Ray とジオメトリの交点の計算概念図

「Ray とジオメトリの交点の計算概念図」。 intersectRayEx 関数を用いて、Ray とジオメトリが交差しない場合は、undefined を返す仕組みになっている

[[SplitPage]]

作例コードの解析

理論はこのくらいにして実際のコードを見てみます。今回は Script Test オペレータを作成します。Script Test は、テスト結果を particleTestStatus というパラメータに設定し、PF はそれを元にパーティクルの振り分けを行います。



----Script Test 001----

on ChannelsUsed pCont do (
	 pCont.useTime = true
	 pCont.usePosition = true
)

on Init pCont do (
)

on Proceed pCont do (
	count = pCont.NumParticles()
	for i in 1 to count do (
		pCont.particleIndex = i

		p = pCont.particlePosition
		d = $LightSrc.pos - p
		r = ray p d

		ret = intersectRayEx $geomSrc r
		if ret == undefined then (
			pCont.particleTestStatus = false
		) else (
			pCont.particleTestStatus = true			
		)

		pCont.particleTestTime = pCont.particleTime
	)
)

on Release pCont do (
)

----

ここでは、 ChannelUsed は飛ばして、いきなり本丸の Processed を見てみましょう。この中も、先ほどの Birth とほとんど同じです。全てのパーティクルに対し処理をするために pCont particleIndex を加算しながら処理をしています。このシーンでは、説明を簡単にするために、計算に使用する光源を "LightSrc" 、交差判定に使用するジオメトリを "geomSrc" という名前で固定しています。



		p = pCont.particlePosition
		d = $LightSrc.pos - p
		r = ray p d

まずは計算に使用する Ray を作成します。 p でパーティクルの位置、 d が方向になります;



		ret = intersectRayEx $geomSrc r

intersectRayEx を使用して、ジオメトリとの交点を求めます。



		if ret == undefined then (
			pCont.particleTestStatus = false
		) else (
			pCont.particleTestStatus = true			
		)

intersectRayEx の結果が undefined であれば Ray とジオメトリが交差しない(=陰にならない)ので、テスト結果を false 、そうでなければ true にします。



		pCont.particleTestTime = pCont.particleTime

このテストを行なった時間を記録します。これで、ひと通りの機能を作ることができました。どうでしょう? 必要な機能をひとつひとつ細かく分解していけば意外とシンプルなパーツの組み合わせでできていると思いませんでしたか?

サンプルムービーでは、さらに Cube の大きさをダイナミックに変えるという工夫もしています。基本はこれまで解説したものと同じなので、どうやったらできるのか考えてみてください。
これらをひと通り実装した結果、以下のような PF が出来上がりました。

Particle Flow 完成例

Particle Flow 完成例

ところで、実はこのシステムにはバグがあります。ジオメトリとパーティクルの間に光源があっても、パーティクルが陰になっていると誤認識してしまうのです。この不具合も intersectRayEx の結果を巧く使えば解決できるので、興味のある方は挑戦してみてください(正解が分かった方は、メールをぜひ)。

今回は以上になります。盛り沢山な内容で消化するのは大変かもしれませんが、実際に皆さんで試してみて頂き、焦らずにひとつひとつ復習することで必ず習得できますよ。連載の第1回目では、「スクリプトとは?」というところから話が始まっていたのに、 Particle Flow のスクリプトオペレータでパーティクルを操作するところまで到達しましたよ。
今回は PF を使ったちょっと特殊なスクリプトの使い道を解説しました。次回からは、また別の視点からスクリプトの使い方を見ていきたいと思います。

TEXT_痴山紘史(JCGS)
映像制作のためのパイプライン構築をはじめ、技術提供を行なっていくエンジニア集団、「JCGS(日本CGサービス)」の代表取締役......というのは表の顔で、実態は飲み会とCG関連の技術が好きなただのCGオタク。
個人サイト「PHILO式」