Particle Flow 飛彈尾煙 Maxscript 練習

接到一個任務,一個飛行器上面有八顆飛彈,總共會有四十幾台飛行器,已經都有手key的飛行器移動跟飛彈射出去的動畫(這也是個花時間的功夫阿),而我需要做的是,要在這現成的場景上的每顆飛彈加上尾煙。

由於整個表現在畫面上其實所佔不大,尾煙沒有離鏡頭很近,於是決定採用類似遊戲或AE的方式用sprite搞定尾煙的呈現,不然四十幾台用模擬會重死。而因為場景在3ds max,決定就直接用particle flow來做,也當作一個particle flow上首次用maxscript的練習。

總體來說就是要做:偵測飛彈什麼時候發射,並針對發射路徑產生particle。

由於有四十幾台,盡量還是找最快速最程序性的方式,不要用暴力來解決問題。

在這邊做了一個示範場景,用幽浮當飛行器,並簡化數量。

然後也不會將整個流程跑到算圖,注重在寫maxscript的部分。

場景前製準備

在前端user製作時,已經提醒命名方式,並將飛彈的pivot都設在尾端,階層如下圖:

我要做的是,為每個飛彈製作兩個dummy,一個link在飛彈上(叫fly),一個link在幽浮上(叫stay),當兩個dummy偵測彼此距離分開了,就等於飛彈發射了,相當不吃效能且輕鬆簡單。

那第一步就是要創建dummy,因為有四十幾台,不可能一個一個來,於是用簡單的maxscript來自動作業:

missile_list = $*missile* as array --將飛彈蒐集成序列
for i = 1 to missile_list.count do( --重複飛彈數量次數的循環
 --指定這次循環的飛彈
 missile = missile_list[i]

 --創造dummy綁在飛彈上,名稱後面加上_fly
 dmy_fly = dummy transform:missile.transform name:(missile.name+"_fly")
 dmy_fly.parent = missile

 --找到飛彈所屬的ufo,然後複製剛剛的dummy改綁在ufo上,名稱後面加上_stay
 ufo = missile.parent
 dmy_stay = copy dmy_fly
 dmy_stay.name = missile.name+"_stay"
 dmy_stay.parent = ufo
)

要注意的是,執行這個script的時候必須是在所有飛彈都還沒離開幽浮之前的影格數。
而且因為現在場景簡單,飛彈蒐集的方式很隨便,就只是找名字有missile就抓起來,如果場景複雜可能要多點條件篩選。

script執行後,便會得到下面的畫面:

在explorer看到會是這樣:

接下來就是主菜particle flow的部分。

創建粒子

創建一個Empty Flow,並新增一個Birth Script,要用來為每個飛彈生成一個particle。

內容如下:

on ChannelsUsed pCont do
(
 --使用integer來儲存飛彈列表的序號
 pCont.useInteger = true
)

on Init pCont do 
(
 --比較名字的公式
 fn compareNames str1 str2 = stricmp str1.name str2.name

 global fly_list = $ufo*fly* as array --蒐集全部的fly Dummy成一個列表,註冊為global變數
 global stay_list = $ufo*stay* as array  --蒐集全部的stay Dummy成一個列表,註冊為global變數

 --依照名稱排列好,以防萬一
 qSort fly_list compareNames  
 qSort stay_list compareNames
)

on Proceed pCont do 
( 
 t = pCont.getTimeStart() 
 if t == animationrange.start then(  --判斷是動畫第一格才會生成particle
  pCont.AddParticles fly_list.count  --生成跟列表一樣數量的particle
  for i = 1 to pCont.numParticles() do(
   --將列表序號儲存在integer通道
   pCont.particleIndex = i
   pCont.particleInteger = i
  )
 )
)

依這範例來說,在init初始化時產生跟著飛彈動的fly列表,以及原地跟著幽浮的stay列表。
然後在動畫第一格生成六顆particle,每一顆particle都有一個integer編號代表他是對應fly列表跟stay列表的第幾個,之後所產生的particle都會給-1,來跟這六個母體分開,這算是最重要的核心想法。

黏在飛彈上

生成結束後,我們要將particle黏在飛彈身上,才可以去偵測什麼時候離開stay的dummy。

於是新增一個script operator,去做黏在飛彈上的動作:

on ChannelsUsed pCont do
(
 pCont.usePosition = true --用position設定現在位置
 pCont.useInteger = true  --用integer對應列表編號
 pCont.useVector = true  --用vector儲存上一格位置
)

on Proceed pCont do 
(
 count = pCont.numParticles()
 for i = 1 to count do( --針對每個particle做循環
  pCont.particleIndex =  i
  if pCont.particleInteger != -1 then( --如果integer不等於-1時再去設定位置
   id = pCont.particleInteger
   pCont.particleVector = pCont.particleTM[4] --vector先儲存現在位置
   pCont.particlePosition = fly_list[id].pos  --Position再去設定成對應fly列表編號的dummy位置
  )
 )
)

每一格particle都會去移動到fly列表對應的dummy的位置,而fly列表就是飛彈們的位置,另外開啟了一個vector通道去紀錄上一格的位置,這跟偵測integer是否為-1都是之後要用的。

偵測飛彈發射時機

現在,particle會跟著飛彈移動了,然後也有與之對應的stay dummy資料,就可以偵測哪一格particle會跟stay dummy分開,就是飛彈發射的時候,然後送入到下一個事件。

所以接下來,我們要新增Script Test。

on ChannelsUsed pCont do
(
 pCont.usePosition = true --用Position通道偵測位置
 pCont.useInteger = true  --用Integer通道找stay列表對應的dummy
)

on Proceed pCont do 
(
 count = pCont.NumParticles()
 for i in 1 to count do
 (
  pCont.particleIndex = i
  id = pCont.particleInteger
  --以上是每顆particle循環所需的前置,而下面是判斷particle的位置大於5時,將particle通過測試
  if distance pCont.particlePosition stay_list[id].pos > 5 then
  (
   pCont.particleTestStatus = true
  )
 )
)

這邊比較簡單,只是要判斷距離並送到下一個事件,而距離5這個值其實可以設定成變數,會更方便修改。

測試通過後,便要送到下一個事件。

繼續 黏在飛彈上

而下一個事件呢,這些飛彈所屬的particle還是要綁在飛彈上,所以複製(instance)一份剛剛黏在飛彈上的語法,貼在新事件上。

這時候如果沒有出錯的話,播放時應該會如下方動畫這樣。

注意看每個飛彈尾端particle id號碼顏色,起初都是紫色,只在發射進入下一個事件後變成黃色。

產生尾煙particle

或許你會想說,接續只要用內建的spawn產生尾巴就可以了。但其實不能這麼做,因為spawn依靠的值是速度。前面雖然都有設定每格該去的位置,但速度沒有設定到。

那接下來要設定速度去做spawn嗎?

不,這不是最好的做法,原始場景有四十幾個飛行器,飛彈飛行的速率、路徑的彎曲程度、每格採樣次數都會大幅影響生成的效率跟品質。

所以我們就自己做一個改良版的spawn吧!不會被這些因素所影響,路徑走多少,就生成固定數量的particle。

一樣新增一個script operator,這邊可能比較複雜。

on ChannelsUsed pCont do
(
 pCont.usePosition = true  --使用現在位置

 --這邊的integer不是拿來對應列表,而是針對隨路徑生成的particle設定-1,好隔開跟原先particle的差異
 pCont.useInteger = true  

 pCont.useVector = true  --之前設定的上一格位置就是這邊要使用的
 pCont.useFloat = true  --這個流程最重要的核心浮點數,用來計算下一次要生成particle的剩餘距離
)

on Proceed pCont do 
(
 count = pCont.NumParticles()
 step = 5  --這個step是用來定義,每多少距離要生成一次particle
 for n = 1 to count do(  --先針對現有particle做循環
  pCont.particleIndex =  n

  --要先篩選掉integer是-1的particle,也就是黏在飛彈上的particle才能參與生成尾煙的流程(避免有些尾煙particle還沒被送到下一個事件)
  if pCont.particleInteger != -1 then(

   --取出上一格位置到這一格位置的資訊,要在這兩個位置之間產生particle
   origin_pos = pCont.particleVector
   target_pos = pCont.particlePosition
   toward_vector = target_pos-origin_pos
   toward_distance = distance target_pos origin_pos

   --取得剩餘距離,如果沒有宣告過就給0
   rest_value = pCont.particleFloat
   if rest_value == undefined then rest_value = 0

   --判斷有沒有移動,沒移動的話接下來這些程式都不執行
   if toward_distance > 0 then(

    --有移動! 那移動的距離間隔夠產生particle嗎?
    if rest_value + step < toward_distance then(
     --夠產生particle的狀況,去做一個增加step的循環,直到移動的距離再也不能產生particle
     local final_i
     for i = rest_value+step to toward_distance by step do(
      pCont.AddParticle() --產生particle
      pCont.particleIndex = pCont.NumParticles()  --將接下來的狀況主角變成這個新生particle
      new_pos = origin_pos + i/toward_distance * toward_vector  --用現在距離跟目的距離算出要產生particle的位置
      pCont.particlePosition = new_pos
      pCont.particleInteger = -1
      final_i = i  --取得總共行進有增加particle的距離
     )
     pCont.particleIndex =  n  --將主角喚回母particle
     pCont.particleFloat = toward_distance - final_i  --總結最後的剩餘距離
    )else(
     --不夠產生particle的狀況,將剩餘距離除去現在這一段,下一格繼續努力
     pCont.particleFloat = rest_value - toward_distance
    )
   )
  )
 )
)

看起來很繁瑣,但簡單來說,就是先設定了每多少間隔就產生particle,然後拿現在位置跟上次位置的行走距離除以間隔去判斷要生成多少particle,再去處理一些細節眉角,就可以得到一個沿運動路徑均衡產生particle的operator。

判斷送到尾煙事件

接著,要篩選出這些產生的particle給送到下一個,也是最後一個要算成尾煙sprite的事件。

on ChannelsUsed pCont do
(
 pCont.useInteger = true  --採用integer通道判斷
)

on Init pCont do 
(

)

on Proceed pCont do 
(
 count = pCont.NumParticles()
 for i in 1 to count do
 (
  pCont.particleIndex = i
  if pCont.particleInteger == -1 then  --如果integer通道等於-1就送出去
  (
   pCont.particleTestStatus = true
  )
 )
)

因為最一開始母particle都有指定編號,對應飛彈列表,而後來隨距離產生的particle,都設成-1,所以可以很簡單的分類,並送到下一個事件(前面做黏在飛彈上的script有-1的先行判斷也是為此)。

尾煙屬性設定

最後一個事件是做sprite渲染,就是用shape facing去對準攝影機,用material dynamic去播sprite動畫,然後用些spin、speed等參數調整動態,這就不在本篇討論範圍,在此用個簡單的預設菱形取代。

到這邊就算完成了,結果如下:

結語

在原始專案,七百多格,四十幾台飛行器,每台飛行器八顆飛彈,也是五分鐘就模擬完,負荷相當輕。

particle flow上的maxscript,說實話很難搞,邏輯跟houdini相當不同,效能差,bug也頗多,但首次練習是覺得滿新鮮有趣的,不過下次如果硬要在max做還是用TP吧!

另外有一個小瑕疵是,算圖時要先bake起來,不然有時候不會每格都去做計算,這可能是對particle flow的script不夠熟悉而有的錯誤,所以在專案製作時是先bake成xmesh。

或許會覺得說,bake成xmesh那age那些資訊不就跑掉了,但其實這些資訊都可以用mapping保留的。

像這邊我在最後加了一個mapping,去key U值紀錄每個particle在最後一個尾煙事件的時間,存在channel 2。

這樣之後的xmesh上材質時,就可以在透明度上一層gradient ramp(注意紅圈勾起的微調),去控制隨著事件時間漸淡,甚至也可以應用在其他地方,做成離火箭尾端近的地方有橘紅色火光之類。