靈活的2D粒子系統設計
來自專欄 korok引擎開發
2D粒子系統一直是網上討論比較多的問題,粒子系統也是Cocos2D-X的一個傷疤,見 Improve the performance of 2D particle system · Issue #11566 · cocos2d/cocos2d-x,不過公平的說Cocos 現在的粒子系統在現有的設計框架下已經做的很好了,上述的性能問題也早就不是問題。不過本文將從一個全新的角度來看待粒子系統的問題,闡述 Korok 的粒子系統設計。
- 現有系統的缺陷
- 新的設計思路
- 具體實現細節
現有系統的缺陷
確切的說,Cocos2D 或 Love2D 是基於配置的粒子系統,它的表現能力取決於已經設計好的模擬模型。閱讀過 Cocos2D/Love2D 的粒子系統會發現它們實現的邏輯幾乎是一致的,唯一的不同在於 Love2D 中使用一個 對象來表示一個粒子(AOS),Cocos 中是使用數組來表示每個屬性(SOA)。其實 Love2D 當前的實現就是 Cocos2D 老版本的實現,有點性能優化經驗的同學會立刻會心其中的區別。
Cocos2D 提供了各種效果(雪花/火焰/霧等)的粒子模擬,但是實際上只有兩種:
- 基於笛卡爾坐標系的物理模擬(ModeA)
- 基於極坐標的物理模擬(ModeB)
事實上基於極坐標和基於笛卡爾坐標的效果是可以互相轉化的只是描述方式的區別,所以本質上只有一種,在這種模擬系統中粒子的屬性是確定的:
class CC_DLL ParticleData{public: float* posx; float* posy; float* startPosX; float* startPosY; float* colorR; float* colorG; float* colorB; float* colorA; float* deltaColorR; float* deltaColorG; float* deltaColorB; float* deltaColorA; float* size; float* deltaSize; float* rotation; float* deltaRotation; float* timeToLive; unsigned int* atlasIndex; //! Mode A: gravity, direction, radial accel, tangential accel struct{ float* dirX; float* dirY; float* radialAccel; float* tangentialAccel; } modeA; //! Mode B: radius mode struct{ float* angle; float* degreesPerSecond; float* radius; float* deltaRadius; } modeB;
這是來自 Cocos2D 的一份 代碼片段,這份清單說明了 Cocos2D/Love2D 的粒子系統可以模擬的效果的上限。你無法在此基礎上設計出超出這種數學模型之外的粒子效果,如果想那麼只能從頭開始。同樣如果你想做最簡單的粒子模擬比如下雨效果,此時你不需要RGB的變化,也不需切線加速/徑向加速等等的變化,但是在這套系統中因為之前建模的緣故這些數據欄位還是參與計算的,那麼這就造成了額外的性能損失。如果減少一半的計算量,直觀的感覺是性能可以提高50%。
這種設計方式局限了粒子系統的發揮空間也制約了性能的進一步提升。如果我想創建從未有過的粒子效果,比如讓粒子構成一個人形墜落,用粒子製造閃電這些操作在當前實現使用都頗為難實現。
新的設計思路
解決這個問題我們需要設計新的粒子系統,提供更強的靈活性。仔細分析粒子系統我們發現粒子的執行分三個階段:
- 粒子的初始化 - 決定了粒子的形狀
- 粒子的模擬 - 決定了粒子的變化方向
- 粒子的可視化 - 把粒子顯示到屏幕上
靈活的粒子系統需要讓用戶來控制粒子如何初始化,如何模擬。如果要實現靈活的模擬那麼需要拋棄寫死的模擬邏輯,而讓用戶來創造粒子的模擬軌跡,如果可以定義:
- 隨機粒子的大小為 2-5 之間
- 粒子以 g = 9.8 加速下落
- 給粒子施加一個水平的速度 v = 2.5
在渲染的時候放上一個雨的紋理那麼這就是 細雨斜飛 的粒子效果。看起來很簡單!!
在 Korok 的粒子系統中可以用下面的代碼來描述上面的規則:
func (sim *SnowSimulator) Simulate(dt float32) { if new := sim.Rate(dt); new > 0 { sim.NewParticle(new) } n := int32(sim.live) // update old particle sim.life.Sub(n, dt) // position integrate: p = p + v * t sim.pose.Integrate(n, sim.velocity, dt) // GC sim.GC(&sim.Pool)}
用代碼實現這個規則更為簡單,它的語法類似於高中物理的計算公式。認真解釋一下上面的語句大概為:
- 按照發射頻率,發射新的粒子
- 更新粒子的生命,減去當前時間步 dt
- 對粒子的位置使用速度積分
- 回收死掉的粒子
以上就是 Korok 的粒子系統實現(上述代碼片段是我們的雪花模擬器的部分代碼)。在 Korok 中實現一個粒子系統就是實現一個模擬器,它的介面如下:
type Simulator interface { // Initialize the particle simulator. Initialize() // Run the simulator with delta time. Simulate(dt float32) // Write the result to vertex-buffer. Visualize(buf []gfx.PosTexColorVertex, tex gfx.Tex2D) // Return the size of the simulator. Size() (live, cap int)}
當然這種設計方式也有很明顯的缺點,它提高了開發人員的要求,至少需要一定的物理知識和一定的想像力。但是一旦你掌握了這總設計方法,你會發現它無所不能。
為了兼容 Cocos2D/Love2D 這種基於配置的開發模式,我們內置了兩個模擬器實現:
- GravitySimulator 對應於 ModeA 的模擬模式
- RadiusSimulator 對應於 ModeB 的模擬模式
使用這兩種模擬器只要提供對應的配置就可以了,這和 Cocos2D/Love2D 是一樣的。使用這種模式重新實現 ModeB 的核心模擬代碼(代碼不到 200 行):
func (r *RadiusSimulator) Simulate(dt float32) { if new := r.Rate(); new > 0 { r.newParticle(new) } n := int32(r.live) r.life.Sub(n, dt) r.angle.Integrate(n, r.angleDelta, dt) // 對極角進行積分 r.radius.Integrate(n, r.radiusDelta, dt) // 對極經進行積分 // 極坐標轉換為笛卡爾坐標 for i := int32(0); i < n; i ++ { x := float32(math.Cos(r.angle[i])) * r.radius[i] y := float32(math.Sin(r.angle[i])) * r.radius[i] r.pose[i] = f32.Vec2{x, y} } r.color.Integrate(n, r.colorDelta, dt) r.size.Integrate(n, r.sizeDelta, dt) r.rot.Integrate(n, r.rotDelta, dt) // recycle dead particle r.GC(&r.Pool)}
ModeB 是用極坐標進行模擬的,關鍵語句就2兩行(代碼中已有注釋)分別對極角和極經積分,之後轉化為笛卡爾坐標進行渲染。
具體實現細節
在具體的設計上的,我們參考了 Bitsquid 計算通道的概念,這是一種面向數據的設計方式,本質上和 Cocos2D 的 SOA 類似,只是這裡把 SOA 做成了一個基礎的可復用的模塊,比如一個 float channel 就是一個 float[] 的數組,然後給這個數組內置一些基礎的演算法,比如隨機初始化/加/減/積分等。這些方法都是針對整個數組進行的,比如:
func (ch channel_f32) Integrate(n int32, ch1 channel_f32, dt float32) { for i := int32(0); i < n; i++ { ch[i] += ch1[i] * dt }}
這段代碼使用 ch1 通道的數據對 ch 通道的前n個 float 進行積分。而實際的使用場景我們在之前已經見到了很多
sim.life.Sub(n, dt) // 這是一個f32的計算通道sim.pose.Integrate(n, sim.velocity, dt) // 這是一個vec2的計算通道
每個 channel 我們只提供了幾個簡單的數學方法,通過組合它們可以實現無數種效果。而 channel 本身是沒有含義的,他就是一個float[]數組或者是一個 vec2[] 的數組,讓開發者來定義它的意義。
目前我們提供了三種計算通道實現:
- channel_f32 可以用來表示時間,速度標量等一維數據
- channel_v2 可以表示位置,速度,加速度等二維數據
- channel_v4 可以用來表示4個顏色通道(可以分別用4個channel_f32來表示RGBA)
每個通道大概有6個方法(不同通道實現略有不同):
- SetConst(n int32, v float32) 初始化所有元素為常量
- SetRandom(n int32, v Var) 初始化所有元素為隨機值
- Add(n int32, v float32) 所有元素做 + 操作
- Sub(n int32, v float32) 所有元素做 - 操作
- Mul(n int32, v float32) 所有元素做 * 操作
- Integrate(n int32, ch1 channel_f32, dt float32) 使用 ch1 通道對當前通道積分
基於計算通道你可以寫出的靈活的高性能的代碼(自帶內存連續特性),如果我們不需要實現徑向加速度和切線加速度,那麼可以完全不要,初始化這些通道的代碼如下:
func (f *FireSimulator) Initialize() { f.Pool.Initialize() f.life = f.Field(Life).(channel_f32) f.size = f.Field(Size).(channel_f32) f.pose = f.Field(Position).(channel_v2) f.velocity = f.Field(Velocity).(channel_v2) f.color = f.Field(Color).(channel_v4) f.RateController.Initialize(f.Config.Duration, f.Config.Rate)}
這是我們實現的一個火焰效果使用的計算通道,用了5個通道,分別表示火焰的生命/大小/位置/速度/顏色。注意,雖然我們單獨申請了5個通道,但是這個五個通道在內存中佔用的還是一整塊內存,我們通過 pool.go 來實現了這部分邏輯。
至此你應該對計算通道有一個初步的概念,模擬邏輯再上一節也有所介紹。那麼如何實現可視化呢?渲染粒子是比較比較固定的功能,因為渲染一個粒子系統總是會需要位置/顏色/紋理,所以我們內置了一個小小的組件來輔助渲染:
type VisualController struct { pose channel_v2 color channel_v4 size channel_f32}
它會默認使用這三個通道的數據來渲染粒子,你需要在初始化的時候分別給這三個通道申請內存。它做的事情非常簡單就是把粒子頂點坐標和UV坐標寫入VBO而已。
這種設計方式也非全新的概念,主流的3D引擎的粒子系統都具有非常高的靈活性,而非 Cocos2D/Love2D 這種簡單的基於配置的實現,如果你沒有接觸過其它的遊戲引擎,可能會一時難以接受,但是一旦你閱讀完我們的內置的模擬器實現,比如 火焰模擬, 雪花模擬,你會發現這套系統非常簡單。如何實現粒子系統取決於引擎的定位,Cocos2D/Love2D 的特點是簡單易用,有非常好的編輯器支持。而 Korok 的實現希望靈活強大,但是也有些缺點比如沒有合適的可視化編輯器支持。
關於我們的引擎項目,你可以關註:KorokEngine/Korok ,也歡迎關注我們專欄.
以上(配圖是 Korok 運行雪花模擬的效果)。
推薦閱讀:
※從零開始手敲次世代遊戲引擎(四十九)
※[GDC16] Assassins Creed Syndicate: London Wasnt Built in a Day
※遊戲開發哪家強 神遊為你插上夢的翅膀
※從零開始寫引擎(OPENGL)(四)-後處理實現
※國內公司開發自研遊戲引擎的意義何在?