Megacity Unity Demo工程學習

HONT發表於2024-08-02

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場景中的內容結構。

  1. Audio存放了音訊配置,MegaCity運用了Unity開放出來的ECS音訊模組DSPGraph,不過當時(指MegaCity Demo釋出時)實現比較簡陋,大概是滿足了基本使用需求的情況。
  2. Pathing存放了飛船的路徑資訊,也是該Demo想展示的一個點。
  3. 玩家飛船相關的邏輯不寫了,這部分沒有用到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

相關文章