![]()
作者:余田
首發“魚塘游戲制作工坊”公眾號
一、前言
溫馨提示,全文5k字,閱讀需要一定程序基礎,歡迎大家關注后慢慢閱讀——
上一篇探討了物理玩法中的操控和移動實現,本篇進行下一步,研究一下如何實現同屏大數量單位的移動和控制,大數量是指幾千數量級,且要支持物理影響這些單位。
我們的難點如下:
- 難點1,數量級要足夠大,手機支持數千到上萬級別
- 難點2,這些單位還要可以受物理影響,有物理效果
- 難點3,不想要基于代理的方式來簡化,也就是每個單位有獨立邏輯
- 難點4,基于組裝,目標多變,并不固定,并且會有多目標
當然,從設計目的出發,我們也有一些簡化問題的機會點:
- 機會點1,限定2D平面上的多單位,不是3D
- 機會點2,不需要過于精確的尋路結果,不追求最短距離的尋路最優解
- 機會點3,底層不需要基于物理,只要和玩家操控的物理底層能夠銜接,表現有物理的感受即可
- 機會點4,不追求精確避障,可以接受偶爾重疊
接下來,我們先來簡單總結一下現有的實現方式都有哪些:
1.基于NavMesh + 局部避障的思路
這是最容易想到的思路,unity本身就支持,比較成熟,優點是尋路的質量比較好,缺點就是性能要求比較高,適合幾百數量級的實體,不符合我們的需求。
![]()
2.VO / RVO / ORCA:基于速度空間的避障(Velocity Obstacle)
這個三個方法一脈相承,主要用于避障,和一般基于位置進行避障的思路不同,簡單來說,這一系列方法的核心就是:如果我現在選這個速度,未來一小段時間會不會撞到別人?把所有“會撞的速度”圈成一個區域,然后在區域外選一個盡可能接近期望速度的安全速度。
![]()
這一類方法雖然避障效果很好,但對于數學計算有一定要求,因此從性能上考慮也不是最佳的選型,研究了一下也pass了,unity里也有現成的實現,大家想要學習,可以上git搜,鏈接在這https://github.com/Nebukam/com.nebukam.orca?tab=readme-ov-file
3.流場尋路(Flow Field)
傳統尋路(A* / NavMesh)是:對每一個單位,從起點到終點求一條離散路徑(拐點列表),單位沿路徑走。
Flow Field 則是以“目標點”為源頭,在整個地圖上鋪一張“勢能場”,每個格子都存一個“朝目標前進的最佳方向”。單位只需要:
- 找到自己所在格子
- 讀取這個格子的“方向向量”
- 朝這個方向加速/移動
換個形象的比喻,把目標點看成一個低洼水坑,整張地圖做一次“地勢計算”,每個格子有一個“高度”(離坑有多遠)。單位就是水滴,只要沿著坡度下降方向走,就會自然流進水坑。
![]()
流場尋路關鍵特點:
- “路徑”不是存在于每個單位身上,而是存在于“地圖格子”上;
- “路徑規劃”是一次全局計算,之后所有單位都廉價復用;
理論上O(1),復雜度跟單位數量無關,只跟流場構建成本有關,配合一些優化流場計算的手段,可以實現極大數量的單位共同運動。
缺點也很明顯,需要目標盡量固定且少量,需要地圖盡量固定,因為“地勢”一變,就得更新流場地圖。所以很適合塔防之類的游戲,我們游戲主角到處移動,未來還考慮多人聯機,因此不太適合。但流場尋路確實很優雅,某些場景可以考慮混合使用。
4.Steering + 射線 + 空間分區避障
由于前面說的,我們不追求完全最優解的尋路,且未來需要結合物理,對性能要求更高,所以最后這是最適合我們的一個方案,足夠簡單高性能,也可以很好地拓展。
整體思路是每個單位不做復雜的尋路,而是:
- 每個單位只知道:想去的“目標方向”(一般就是指向玩家,或者任意目標位置)
- 前面有沒有墻/障礙(射線檢測)
- 如果前方是通的 → 篤定往前沖
- 如果前方有障礙 → 在附近扇形范圍內“試探”幾個方向,找一個能通過的方向
- 加一點點“單位間排斥力”,避免全擠成一團
二、移動具體實現思路
以“單個單位”為例,每一幀大致流程是:
- 計算“理想前進方向”
- 用射線檢測這條方向是否被障礙擋住
- 如果被擋,扇形范圍內尋找替代方向
- 在避障方向基礎上,疊加“和周圍單位保持距離”的偏移
- 在最終方向上做平滑處理,并加一點點隨機擾動防止卡死
- 通過剛體/位移沿這個方向移動
整個過程只決定“方向”,移動本身依然交給剛體物理和碰撞系統處理 [來源]。
下面把每一步展開講。
第一步:理想方向——它本來想往哪兒走?
先只考慮“目標”,不考慮障礙和友方。目標可以是玩家位置、某個路徑點、玩家周圍的某個隨機點等。理想前進方向就是:從自己指向目標的單位向量。這一方向是后面所有計算的“基準”,避障和群體行為都只是對這個基準做修正。如圖:
![]()
第二步:射線檢測——這條路通不通?
有了理想方向之后,下一步是判斷:“如果沿著這個方向往前走,近期內會不會撞到墻或障礙?”
方法很簡單,從單位中心以及單位的邊緣,如果是圓形碰撞器,就是對應方向間隔半徑的兩條射線(可以再略大一點,留點余裕比較好),沿理想方向發射三條射線,這樣可以檢測出去往目標的路徑上有沒有能擋住自己這個體積物體的障礙。射線注意只檢測“墻/障礙”所在的 Layer,優化性能。如圖:
![]()
如果射線前方沒有命中任何障礙,這一幀就可以直接沿理想方向移動,不需要額外繞路。如果射線撞到了障礙,說明正前方有墻或阻擋,進入“扇形掃描”階段:
第三步:扇形掃描 + 記住繞哪邊——選出“次優方向”
當前方被擋住時,單位不會立刻停下,而是在理想方向附近的一個扇形區域里,嘗試若干個稍微偏左、偏右的方向,這個范圍和精度可配,決定了掃描的精度和性能代價,例如配置了120度范圍,每10度發一個射線。順序就是從理想方向開始左右擴散來試探,如圖:
![]()
每試一個方向,都像第二步一樣用射線檢測前方是否有障礙,一旦找到“前方一段距離內都沒有障礙”的那個方向,就把它作為本幀的避障方向。
這里,為避免單位在某些墻角來回左右搖擺,可以給每個單位記錄一個“上次繞障偏向”(例如優先偏右),下一次再遇到障礙時,優先從這一側開始掃描,這樣就能避免在墻角附近“一會兒想從左繞、一會兒又改從右繞”,導致方向抖動。
如果扇形范圍內所有候選方向都被判定為“前方有障礙”,則進入下一步:
第四步:墻滑動——被擋住時別硬頂,沿墻溜過去
當理想方向被障礙擋住時,可以沿著墻的邊緣走,既然正對著墻走不通,那就沿著墻面切線方向滑過去,只要不是封閉空間,總能走出去。
具體做法:
障礙碰撞檢測時能得到一個法線方向(即墻面朝向單位的那一側)
把當前想走的方向拆解成兩部分:
- 垂直墻面的分量(正頂著墻)
- 平行墻面的分量(沿著墻滑)
去掉“垂直墻面、撞墻那一部分”,只保留沿墻的方向
![]()
效果上看就是:
單位不會呆呆地頂在墻上不動,而是自動“貼著墻邊擦過去”。
第五步:多單位避讓,空間分區方法
這一步是防止單位間重疊,肯定不能用碰撞器,性能消耗過大,同時也不能直接兩兩檢測距離,那樣的復雜度就是 O(N2),這里就要用到一個比較通用的方法:空間分區。
空間分區的核心思路就是把地圖劃分成網格,每個單位每幀通過自己的坐標,加入自身所屬網格,每個網格里的單位,只關心自身所在格子和附近網格的單位,例如附近八格,如此,每次計算量就大大縮小。然后對過于靠近自己的鄰居,施加一個推開的傾向,可以越近,力度越大。
![]()
這一步的結果是得到一個“分離向量”:它代表“為了不擠到別人,應該稍微偏移的方向”。
第六步:方向融合 + 平滑 + 隨機擾動——最終行走方向
到這一步為止,我們手里已經有幾種方向信息:
- 理想前進方向(只考慮目標)
- 避障/繞墻后的方向(考慮了墻和障礙)
- 來自鄰居的分離方向(防止擠成一團)
現在要把它們合成一個最終移動方向,并做好“防抖動”處理:
這里可以進行一個加權處理,權重更高的是“避障方向”,因為不撞墻是底線。“分離方向”是其次,讓單位輕微遠離鄰居。兩者合成就是當前的新方向,但是不能直接應用,因為和理想前進方向間,會有一個跳變,就會很奇怪地抖動。為了自然,可以記住一個“當前移動方向”,每幀向新的目標方向緩慢靠近,可以用各種插值方法,不贅述。
最后得出一個行走方向后,再給一個小的隨機擾動,來打破一些極端情況下的僵局,例如兩堵墻對稱,單位在其中尬住。
第七步:生效移動
最終方向確定后,真正的移動可以交給物理系統,也可以直接賦位置,我們選擇后者,因為更省性能,完全不讓多單位參與unity的物理,都由我們自己計算,也為后面的性能深度優化做鋪墊。
三、加入物理影響
整體架構
整體思路是用虛擬的力,來統一銜接物理和非物理部分,對于多單位來說,所有的物理效果,都通過抽象的“力場 ForceData”來實現。我們以兩個典型的物理效果,爆炸沖擊波,以及黑洞引力來舉例:
- 所有爆炸、黑洞等效果,不再直接去改單位,而是統一抽象成力場
- 場景中所有力場由一個ForceManager集中管理和更新
- 每個單位自己持有“受力狀態”(外力速度、是否擊暈等),每幀由 ForceManager 計算它應該受到多少外力
這樣一來:
- 技能/道具只負責“創建某種力場”(比如在某點創建一個爆炸力場)
- ForceManager 負責“這個力場到底對哪些單位、造成多大力”
- 單位只需要把“自己的受力結果”加進最終移動速度即可
架構干凈、可擴展,也便于以后加新類型的力如風場、磁場等,力的數據結構統一抽象成 ForceData后,也可以為后續ECS的深度優化做準備。
力場 ForceData
ForceData 主要字段包括:
- 位置:力場中心(如爆炸點、黑洞中心)
- 類型:爆炸、吸引(黑洞)、定向推力(沖擊波)、漩渦……
- 半徑:影響范圍
- 強度:力的基礎大小
- 持續時間:是瞬時爆炸還是持續吸引
- 時間進度:已經過了多久,用來做時間上的衰減
- 距離衰減指數:力隨距離衰減的曲線(線性、平方等)
- 是否影響“可控單位”:可以控制某些力只作用于怪物,不影響玩家
單位
而每個單位自己,還會有一份“受力狀態”:
- externalVelocity:由外力產生的速度(和自身移動速度區分開)
- stunTime:當前剩余的擊暈時間
- mass:質量,用來控制“同樣爆炸,對重單位擊飛更小,對輕單位更大”
力場管理器ForceManager
ForceManager 是整個系統的“中樞”:
- 內部維護一個活動力場列表:所有仍在生效的爆炸、黑洞等等
每幀會做兩件事:
- 更新所有力場的時間進度,過期的自動移除
- 提供接口給單位/UnitManager:計算某個單位當前受到的總外力
力場的創建非常簡單:
- 爆炸:在某點創建一個“爆炸型力場”,半徑 + 強度 + 持續時間很短
- 黑洞:在某點創建一個“吸引型力場”,半徑大、持續時間長
然后計算單位受力時的邏輯,遍歷所有活動力場,對每個力場:
- 算出單位與力場中心的距離
- 如果超出半徑 → 這個力場對該單位無效,跳過
- 在半徑內 → 按距離衰減、時間衰減計算出當前力的大小
- 根據力場類型,決定方向,爆炸就是從中心向外,黑洞就是指向中心
- 把所有力場對該單位的力向量相加,得到總外力,這樣外力是線性可疊加的,多個爆炸疊在一起會更猛,一個爆炸疊加一個黑洞會產生復雜但可預期的效果。
拿到 ForceManager 給出的“總外力”之后,單位要做的事只有三步:
步驟一:把力轉換成外力速度(擊飛)
- 力 → 沖量 → 速度變化:力越大、單位越輕,速度變化越大
- 把這一部分速度寫入 externalVelocity 中
- 為避免數值過大,給外力速度設一個最大值上限(比如 maxExternalSpeed)
步驟二:判斷是否進入擊暈/失控狀態
- 擊暈單位不能進行自主移動(不能追玩家、不能執行 AI 行走)
- 只剩下“外力產生的慣性速度”和模擬阻力在起作用
- 擊暈時間隨著時間減小,到 0 后恢復正常控制
- 擊暈狀態可以防止出現炸飛還在移動的情況,可以讓被動的運動更加自然。
步驟三:外力速度的自然衰減(模擬阻力)
- 每一幀,都對 externalVelocity 施加一個全局阻力系數
- 當外力速度很小(近似 0)時,直接歸零,避免永遠留著一個極小的殘余值
與原有移動 / 避障系統的融合
如果單位處于擊暈狀態,直接用externalVelocity。如果單位處于正常狀態,則根據之前計算出來的移動速度向量,和externalVelocity進行相加。
四、總結和性能優化
性能優化總體有幾個非常通用的思路,不同工程其實差不多。
- 通過四叉樹八叉樹等空間分區方法,讓多單位間的查詢,從 O(N2) 到 O(N)
- 通過多線程和數據管理來優化,unity里可以用DOTS,甚至放GPU去做復雜的并行計算
- 分幀,平滑每幀的消耗
- LOD思路,重要的單位算細一點,不重要的粗一點,屏幕外看不到的,更粗糙等等
- 能近似就近似,復雜運算簡化,例如這個工程里就會用到的距離的平方來判斷距離,減少開方,復雜函數讀表等等技巧
對于我們上述的基本實現,還沒進行任何優化,可以支持2000個單位60幀率運行,而且是在編輯器下,這還遠遠沒到我們的要求,通過profiler分析(忘記截圖了),不出所料,最耗性能的是射線檢測部分。那我們先進行一波射線檢測放JobSystem多線程處理的快速優化,一通改造代碼,性能提升很明顯,可以做到5000個單位,帶物理效果,100幀左右。最終結果如下:
到這里,初步驗證方案的可行性已經通過,可以給程序去實現了,就不再深入優化了,我只是個可憐的小策劃,理論上按我之前經驗,全套用ECS實現,再加上各種優化,最終能支持數萬個單位同屏,考慮到最后效果復雜之后,縮到幾千個也足夠用了。
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.