1.前言
Megacity Demo釋出於2019年春,本博文撰寫於2024年,ECS也早已Release併發布了1.2.3版本。
不過好在核心變化不大,多數介面也只是換了呼叫名稱,
該Demo相較於之前的Book of the Dead(2018年釋出),體量要小一些,主要演示DOTS相關內容。
近期剛好空閒,並且工程檔案與2019.2版本躺硬碟已久,經典適合反覆研究,故把坑填上。
該Demo已上傳百度網盤:
連結:https://pan.baidu.com/s/1X1gh6hQSRuB0KenlRZsOiw
提取碼:iios
開啟請使用Unity2019.1.0b7,其中Unity Package部分包會從Unity伺服器下載,版本過老,
不保證是否能正確拉取,可以自行修復。
2.Hybrid ECS 部分
先講一講用到Hybrid ECS的幾個功能。
2.1 HLOD
開啟主場景MegaCity.unity後,在任意Section SubScene內,可以看見一些模型都套用有HLOD元件,
HOLD指的是場景內的細碎物件在到達最後一級LOD時,將這些物件的最後一級LOD合併進一個Mesh進行顯示,
例如遠處的三四個房屋,電線杆等等。
合批後將替換為合併Mesh的單個模型,而模型合併操作可以離線進行,提前生成好。
HOLD的缺點是記憶體中需要多放置HLOD模型,並且存在負最佳化的情況,具體看專案而定。
在MegaCity Demo中可透過指令碼CombineMeshFromLOD.cs
進行HLOD模型的離線建立。
而HLOD指令碼則是Hybrid ECS內封裝了部分功能,透過ECS計算HLOD的顯示替換等一些邏輯處理,使用時需要確保LOD Group元件的LOD數量
與HLOD中的LodParentTransforms一致即可,例如下圖中有2個Low LOD的GameObject,實際上是2個級別的HLOD:
(理論上是單個HLOD Mesh替換,但實際Unity支援多級別HLOD)
2.2 SubScene
SubScene是Unity透過DOTS實現的子場景巢狀功能,其核心博主認為是Unity開放的流式場景載入介面:
m_Streams[i].Operation = new AsyncLoadSceneOperation(entitiesBinaryPath, sceneData.FileSize, sceneData.SharedComponentCount, resourcesPath, entityManager); m_Streams[i].SceneEntity = entity;
同時SubScene也附帶了將場景內容轉換為適合流式載入的二進位制格式
3.ECS的一些常見概念
在開始看MegaCity之前,我覺得應該先寫一些ECS的前置概念。
3.1 篩選機制
常規編寫一個Manager類會透過註冊(Register)/反註冊(Unregister)的機制管理該類的物件,
而ECS中這樣的邏輯變為了篩選機制,以MegaCity的BoxTriggerSystem
為例,這是一個類似處理OnTriggerEnter
事件觸發的碰撞管理系統,
碰撞盒的註冊透過HybridECS的Mono轉換元件進行:
ECS的System中,篩選程式碼如下:
m_BBGroup = GetComponentGroup( new EntityArchetypeQuery { All = new ComponentType[] { typeof(BoundingBox) }, None = new ComponentType[] { typeof(TriggerCondition) }, Any = Array.Empty<ComponentType>(), });
其中含有BoundingBox
的ComponentData將會被篩選到對應System中進行處理。
而傳統Manager的Unregister操作在ECS中則是將這個ComponentData移除,這樣下一幀篩選時就不會篩選到了。
3.2 Jobs中CommandBuffer處理
還是以MegaCity Demo的BoxTriggerSystem
為例,struct Job用於處理多執行緒的各項任務,並可以透過Burst對底層程式碼進行加速,
而在Job中不能進行如ComponentData移除這樣的刪改操作,我們可以透過CommandBuffer來加入到操作佇列,在Job結束之後進行處理,
這和渲染管線處理上的CommandBuffer有點像:
public struct TriggerJob : IJobChunk { public EntityCommandBuffer.Concurrent m_EntityCommandBuffer; //... public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) { //... // add trigger component m_EntityCommandBuffer.AddComponent(chunkIndex, newBoundingBox, new TriggerCondition()); } }
3.3 標記邏輯處理
那麼像BoxTriggerSystem
這樣的碰撞管理器,如何對已經產生碰撞的物件進行標記?
其實也是透過篩選處理的,在產生碰撞後為對應Entity實體增加一個ComponentData - TriggerCondition
:
m_EntityCommandBuffer.AddComponent(chunkIndex, newBoundingBox, new TriggerCondition());
篩選時跳過含有TriggerCondition的實體即可:
m_BBGroup = GetComponentGroup( new EntityArchetypeQuery { All = new ComponentType[] { typeof(BoundingBox) }, None = new ComponentType[] { typeof(TriggerCondition) }, Any = Array.Empty<ComponentType>(), });
而在另一個音樂處理的System中,又會拿到標記了TriggerCondition
和MusicTrigger的實體:
m_TriggerData = GetComponentGroup( new EntityArchetypeQuery { All = new ComponentType[] { typeof(TriggerCondition), typeof(MusicTrigger) }, None = Array.Empty<ComponentType>(), Any = Array.Empty<ComponentType>() });
所以ECS的思路就是透過標記來代替傳統OnEnable/OnDisable訊息事件的觸發。
4.MegaCity Demo本體
4.1 場景結構
先來看下靜態置於MegaCity場景中的內容結構。
- Audio存放了音訊配置,MegaCity運用了Unity開放出來的ECS音訊模組DSPGraph,不過當時(指MegaCity Demo釋出時)實現比較簡陋,大概是滿足了基本使用需求的情況。
- Pathing存放了飛船的路徑資訊,也是該Demo想展示的一個點。
- 玩家飛船相關的邏輯不寫了,這部分沒有用到DOTS
4.2 LightPoolSystem
LightPoolSystem主要是用ECS的形式,遍歷當前飛船和相機視錐範圍內的燈光,進行邏輯篩選並進行物件池複用。
因為藉助了HDRP渲染管線,場景內的燈光將和體積霧效果產生互動,達到較好的顯示呈現。
其中LightRef.cs
指令碼用於將場景中的燈光轉換進ECS:
來到LightPoolSystem的OnUpdate中,對其中邏輯進行快速講解:
protected override JobHandle OnUpdate(JobHandle handle) { if (Camera.main == null || !AdditiveScene.isLoaded) return handle; #region Setup new lights #region Find closest lights #region Assign instances #region Update light intensity return handle; }
1).第一步Setup new lights,拿到沒有標記LightPoolCreatedTag
元件資料的SharedLight,篩選結構如下:
m_NewSharedLights = GetComponentGroup ( ComponentType.ReadOnly<SharedLight>(), ComponentType.Exclude<LightPoolCreatedTag>() );
SharedLight就是場景中HybridECS的轉換物件,對應的MonoBehavior轉換指令碼是LightRef.cs
假設場景內當前載入了50盞燈光,那麼這一步也會建立50個實體,但對應的物件池則是用到了哪種燈光模板在惰性建立。
這一步最後再標記上LightPoolCreatedTag
,防止下一次Update時進入這部分邏輯。
2).第二步Find closest lights,對已經對映上的場景燈光進行視錐和距離篩選,存入另一份NativeArray - ClosestLights。
3).第三步Assign instances分配例項,對已經篩選出來的實體分配具體燈光,並存入另一份NativeArray - AssignedLights,方便後續操作。
4).第四步Update light intensity更新燈光強度,直接操作AssignedLights更新燈光亮度,對於Active標記為False的燈光,
將不斷變暗直到亮度數值為0並進行回收。
4.3 StreamingLogic
流式載入場景的封裝邏輯,因為Unity SubScene並沒有完全封裝對應的載入解除安裝邏輯處理,
只提供了介面,我們還需要額外編寫一層邏輯。
玩家物件上掛有配置指令碼StreamingLogicConfigComponent
處理流式載入的引數:
然後System中進行少量邏輯處理,最後用掛載ComponentData的方式通知Unity ECS的流失載入系統進行載入:
struct BuildCommandBufferJob : IJob { public EntityCommandBuffer CommandBuffer; public NativeArray<Entity> AddRequestArray; public NativeArray<Entity> RemoveRequestArray; public void Execute() { foreach (var entity in AddRequestArray) { CommandBuffer.AddComponent(entity, default(RequestSceneLoaded)); } foreach (var entity in RemoveRequestArray) { CommandBuffer.RemoveComponent<RequestSceneLoaded>(entity); } } }
4.4 Megacity Audio System
或許這個系統才是重點,但發現最最重要的部分仍是Unity封裝好的介面。
首先在Package Manager中可以看見該系統的相關程式碼,同時也可以發現AudioMixer中空空如也,這也MegaCity Demo的不同之處,
其內部所有的音訊都是基於這套系統開發的。
在專案宏定義處加上ENABLE_DSPGRAPH_INTERCEPTOR宏以開啟偵錯程式:
開啟後可以在Window/DSP Graph處開啟偵錯程式視窗,可看見所有的音訊Graph結構最終如何彙總輸出:
Megacity demo中飛機之間快速擦過(FlyBySystem)以及交通中的各類音訊都是呼叫了這個System
其中ECSoundEmitterComponent.cs
可掛載,類似於AudioSource:
遊戲內的音訊會先掛載到PlaybackSystem,好比先把Audio放置於Graph內,再將音訊暫時關閉,需要時開啟:
var playbackSystem = World.Active.GetOrCreateManager<SamplePlaybackSystem>(); playbackSystem.AddClip(clip);
而真正去用,則是其他地方另行處理,可以看見讀取快取的AudioClip透過GetInstanceID:
var sample = EntityManager.CreateEntity(); AddClip(clip); EntityManager.AddComponentData(sample, new AdditiveState()); EntityManager.AddComponentData(sample, new SamplePlayback { Volume = 1, Loop = 1, Pitch = 1 }); EntityManager.AddComponentData(sample, new SharedAudioClip { ClipInstanceID = clip.GetInstanceID() }); m_SampleEntities.Add(sample);
最後看音效實現,好像沒有對應介面,也是透過類似掛載AudioClip的方式,定時播放和移除掛載。
其思路和Wwise/FMod也不相似,沒有事件邏輯,只是效能系統設計。
4.5 Traffic 交通邏輯處理
這是MegaCity Demo中最讓我眼前一亮的模組。
4.5.1 道路處理
MegaCity Demo中玩家路徑用的是Cinemachine Path,NPC飛船用的路徑是自己寫的Path.cs:
若需要編輯Path,需要勾選Show All Handles,Show Coloured Roads則是檢視路網的開關。
Is On Ramp用於標記主幹道(匝道),Percetage Chance For On Ramp用於標記從分支進入主幹道的機率。
勾選Show Coloured Roads:
4.5.2 NPC飛船尋路處理
NPC飛船透過Path拿到道路資訊,並且透過CatmullRom插值進行路徑計算,非常巧妙的一點是它利用了
CatmulRom的導數得到曲線變化率,並以此直接作為係數實現飛船移動的勻速曲線取樣:
public void Execute(ref VehiclePathing p, ref VehicleTargetPosition pos, [ReadOnly] ref VehiclePhysicsState physicsState) { var rs = RoadSections[p.RoadIndex]; float3 c0 = CatmullRom.GetPosition(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos); float3 c1 = CatmullRom.GetTangent(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos); float3 c2 = CatmullRom.GetConcavity(rs.p0, rs.p1, rs.p2, rs.p3, p.curvePos); float curveSpeed = length(c1); pos.IdealPosition = c0; pos.IdealSpeed = p.speed; if (lengthsq(physicsState.Position - c0) < kMaxTetherSquared) { p.curvePos += Constants.VehicleSpeedFudge / rs.arcLength * p.speed / curveSpeed * DeltaTimeSeconds; } }
其中c1是一階導數,c2二階導數。最後一行計算時為什麼還要除以arcLength不太清楚。
4.5.3 NPC飛船生成
在VehicleSpawnJob.cs中可以檢視場景中飛船生成的程式碼,其中在處理飛船UID時,使用了原子性加減操作:
public void Execute(Entity entity, int index, ref Spawner thisSpawner) { if (thisSpawner.delaySpawn > 0) thisSpawner.delaySpawn--; else { RoadSection rs = RoadSections[thisSpawner.RoadIndex]; Interlocked.Increment(ref vehicleUID); float backOfVehiclePos = thisSpawner.Time - rs.vehicleHalfLen; float frontOfVehiclePos = thisSpawner.Time + rs.vehicleHalfLen; ...
即使在多執行緒下,也可以使變數安全的遞增。
在函式GetSpawnVehicleIndex內有一段位運算操作:
public int GetSpawnVehicleIndex(ref Unity.Mathematics.Random random, uint poolSpawn) { if (poolSpawn==0) return random.NextInt(0, VehiclePool.Length); // Otherwise we need to figure out which vehicle to assign // Todo: could bake the num set bits out! uint pool = poolSpawn; uint numSetBits = poolSpawn - ((poolSpawn >> 1) & 0x55555555); numSetBits = (numSetBits & 0x33333333) + ((numSetBits >> 2) & 0x33333333); numSetBits = ((numSetBits + (numSetBits >> 4) & 0x0F0F0F0F) * 0x01010101) >> 24; // we now have a number between 0 & 32, int chosenBitIdx = random.NextInt(0, (int)numSetBits)+1; uint poolTemp = poolSpawn; uint lsb = poolTemp; //TODO: make the below better? while(chosenBitIdx>0) { lsb = poolTemp; poolTemp &= poolTemp - 1; // clear least significant set bit lsb ^= poolTemp; // lsb contains the index (1<<index) of the pool for this position chosenBitIdx--; } float fidx = math.log2(lsb); return (int) (fidx); }
首先路段允許存在哪些飛船是掩碼進行控制,
poolSpawn引數是掩碼,如果掩碼為0則直接隨機跳過,
然後透過下面這部分找到這個掩碼中有多少個1
uint numSetBits = poolSpawn - ((poolSpawn >> 1) & 0x55555555); numSetBits = (numSetBits & 0x33333333) + ((numSetBits >> 2) & 0x33333333); numSetBits = ((numSetBits + (numSetBits >> 4) & 0x0F0F0F0F) * 0x01010101) >> 24;
再用這個數進行隨機:
int chosenBitIdx = random.NextInt(0, (int)numSetBits)+1;
最後挨個去看,對應這個數字的掩碼是多少,返回索引。
4.5.5 飛船銷燬
當飛船沿著路徑飛至盡頭則直接銷燬
public struct VehicleDespawnJob : IJobProcessComponentDataWithEntity<VehiclePathing> { public EntityCommandBuffer.Concurrent EntityCommandBuffer; public void Execute(Entity entity, int index, [ReadOnly] ref VehiclePathing vehicle) { if (vehicle.curvePos >= 1.0f) { EntityCommandBuffer.DestroyEntity(index, entity); } } }
4.5.6 Lane/Occupation 車道佔用處理
不同的NPC飛船有不同的車道,如果車道被佔用則會在Job裡調起換道邏輯,
如果車道前方通暢,則會進行適當加速。
4.5.7 避障處理
透過Hash演算法,對空間中每個格子進行資料量化並轉換為Hash,程式碼在VehicleHashJob.cs中,
public static int Hash(float3 v, float cellSize) { return Hash(Quantize(v, cellSize)); }
Cells是一個多值雜湊表:
[ReadOnly] public NativeMultiHashMap<int, VehicleCell> Cells;
意味著可以存放每個Hash格子裡的所有載具資料。
當發現障礙物時,嘗試透過叉乘得到方向並迭代迴圈當前格子裡的所有載具,
並且儘可能避開障礙:
do { // For the vehicle in the cell, calculate its anticipated position float3 anticipated = cell.Position + cell.Velocity * TimeStep; float3 currDelta = pos - cell.Position; float3 delta = anticipated - ownAnticipated; // Don't avoid self if (lengthsq(currDelta) < 0.3f) continue; float dz = dot(delta, vnorm); // Ignore this vehicle if it's behind or too far away if (dz < 0.0f || dz > maxScanRangeMeters) continue; float lsqDelta = lengthsq(delta); // Only update if the distance between anticipated positions is less than the current closest and radii if (lsqDelta < closestDist && lsqDelta < (cell.Radius + radius) * (cell.Radius + radius)) { float dx = dot(delta, right); float dy = dot(delta, up); closestDist = lsqDelta; xa = dx; ya = dy; mag = cell.Radius + radius; } } while (Cells.TryGetNextValue(out cell, ref iter));
5.雜項
5.1 ComponentDataFromEntity<T>透過實體快速對映元件
以Demo中的程式碼為例:
foreach (var newFlyby in New)//New = Entities { var positional = PositionalFromEntity[newFlyby];
可以透過這個類直接得到元件,目前在新版本ECS中該類改名為了:
ComponentLookup<T>
5.2 DelayLineDopplerHack
這個指令碼放在了Script資料夾外,並沒有在專案裡實裝,它用了比較HACK的方法直接處理音訊,並且
嘗試實現哈斯HAAS效應:
var haasDelay = (int)((s.m_Attenuation[0] - s.m_Attenuation[1]) * m_Haas * (c * 2 - 1)); var delaySamples = Mathf.Clamp (delaySamplesBase + haasDelay, 0, maxLength);
哈斯(Haas)透過實驗表明:兩個同聲源的聲波若到達聽音者的時間差Δt在5~35ms以內,人無法區分兩個聲源,給人以方位聽感的只是前導聲(超前的聲源),滯後聲好似並不存在;若延遲時間Δt在35~50ms時,人耳開始感知滯後聲源的存在,但聽感做辨別的方位仍是前導聲源;若時間差Δt>50ms時,人耳便能分辨出前導聲與滯後聲源的方位,即通常能聽到清晰的回聲。哈斯對雙聲源的不同延時給人耳聽感反映的這一描述,稱為哈斯效應。這種效應有助於建立立體聲的聽音環境
5.3 ChunkEntityEnumerable
透過工具類ChunkEntityEnumerable
,簡化了在Job中遍歷Chunk時的翻頁處理:
public bool MoveNext()
{
if (++elementIndex >= currChunkLength)
{
if (++chunkIndex >= chunks.Length)
{
return false;
}
elementIndex = 0;
currChunk = chunks[chunkIndex].GetNativeArray(entityType);
currChunkLength = currChunk.Length;
}
return true;
}
Unity2022新版MegacityDemo下載:https://unity.com/de/demos/megacity-competitive-action-sample
Unity多人聯機版本Megacity: https://unity.com/cn/demos/megacity-competitive-action-sample
Unity2019舊版本Megacity下載:https://discussions.unity.com/t/megacity-feedback-discussion/736246/81?page=5
Book of the Dead 死者之書Demo工程回顧與學習:https://www.cnblogs.com/hont/p/15815167.html