基于LightProbes的動態(tài)全局光照(GI)方案探討

前言

問題是這樣的,移動端開放世界的全局光照(GI)方案應(yīng)該如何實(shí)現(xiàn)?實(shí)時(shí)的光照計(jì)算(Realtime GI)肯定少不了,可是來自天空盒,地表以及四周山石森林等的環(huán)境光漫反射又如何表現(xiàn)呢?關(guān)于這點(diǎn)項(xiàng)目組的同學(xué)都缺乏經(jīng)驗(yàn),于是我本著探索精神結(jié)合Unity框架調(diào)研了一番,期間總結(jié)了幾點(diǎn)感覺對大家有益的經(jīng)驗(yàn),發(fā)在這里以供查閱。

1.1起點(diǎn)

但是在展開之前需要先明確下限制條件:

  1. 首先,平臺是移動端的,也就意味著較低的算力和數(shù)據(jù)帶寬,那么很多算法復(fù)雜且消耗大量資源的方案就不用考慮了(光追?)
  2. 其次是開放世界,簡言之場景數(shù)百甚至數(shù)千倍于傳統(tǒng),那么為了不讓執(zhí)行烘焙的設(shè)備暴斃,就得著手給單次烘焙降降壓:降低品質(zhì)+分治。
  3. 再然后是前文沒有提到的,游戲時(shí)間需要支持日夜交替(Time of Day)和/或天氣系統(tǒng)(Weather System)。

1.2最簡單的方案

OK,現(xiàn)在我們圍繞Unity引擎來考量下能有哪些方案可用吧。

Unity有一套完整的GI方案,可以在Window -> Rendering -> LightSetting中找到它的設(shè)計(jì)面板。這套官方的方案總的來說比較復(fù)雜,因?yàn)樗m應(yīng)不同用戶的需求,比如依據(jù)不同的設(shè)置,GI會在動態(tài)和靜態(tài)之間,細(xì)節(jié)豐富程度和擬真程度上形成區(qū)別,而且越是好的效果,對存儲和計(jì)算的開銷越大??梢愿爬ㄆ鸬恼f,Unity通過烘焙(基于PBR的預(yù)計(jì)算)的方式,重建了場景中各個(gè)表面的光照數(shù)據(jù),并分類存儲到諸如Lightmap、ShadowMask、LightProbe等數(shù)據(jù)載體中,在運(yùn)行的時(shí)候,再快速反解出這些光照數(shù)據(jù),以供頂點(diǎn)和片元使用。具體細(xì)節(jié)內(nèi)容不是這篇博文的重點(diǎn),就不再單獨(dú)深入下去了,之后有機(jī)會再單開一篇介紹這套GI方案,這里丟一個(gè)個(gè)人覺得比較好的官方解釋文檔。

基于Unity提供的方案來說,什么是最省時(shí)省力的做法呢?我覺得是放棄使用任何額外的預(yù)計(jì)算光照,只在計(jì)算物體表面顏色時(shí),附帶一層定制的環(huán)境光底色(Ambient),或者復(fù)雜一些,為角色的頭頂+側(cè)面+腳底3個(gè)方向各附加一層專門的環(huán)境光底色,這種做法的復(fù)雜點(diǎn)在于需要利用角色模型的法線紋理,過濾出朝上的部分,朝上的反方向部分,以及其余所有部分作為遮罩。雖然效果比預(yù)計(jì)算GI顯得單調(diào)了些,也不支持隨場景變化,但是貴在節(jié)約性能,操作簡便。所以只要美術(shù)同學(xué)不在意這部分模糊的環(huán)境光,那么大可以省去烘焙的部分。

1.3 Lightmap的取舍和原因

說到Unity全局光照方案,就繞不過光照貼圖(Lightmap)。Lightmap的本質(zhì)是一系列的等尺寸的貼圖紋理,紋理上存儲的是經(jīng)過預(yù)計(jì)算后得到的物體表面光照信息(一般包含了直接光照和間接光照的效果)。更加形象點(diǎn),Lightmap上存儲的是所有參與烘焙的物體的表面顏色在2D空間上展開的貼圖,運(yùn)行時(shí)配合著物體第二套UV以及一份特有的參數(shù)(縮放+下標(biāo)),直接讀取貼圖上的顏色進(jìn)行顯示??梢哉f在處理室內(nèi)靜態(tài)場景時(shí),使用Lightmap會帶來性能和效果上的巨大優(yōu)化,然而對于空間龐大的室外場景來說,我們有太多需要烘焙的物體,如果把一個(gè)開放世界看做一個(gè)場景,那么無腦烘焙的結(jié)果必然是海量的光照貼圖(假如沒爆內(nèi)存的話)。那么分場景烘焙+運(yùn)行時(shí)動態(tài)載入的方法可行么?首先回答是可行,雖然在Unity的這套框架里L(fēng)ightmap是跟著場景(Scene)走的,但是不妨礙我們每次只Load一小塊地塊的Lighting Data Asset到當(dāng)前的全局場景,然后可以參考這篇博文的方法,流式的加載場景上的物體,只要這些物體預(yù)設(shè)了修正過的UV2,就能正確的采樣到Lightmap。然而如博文最后所說的,這種方法的最大問題是人為打斷了Unity對資源的合批,導(dǎo)致包體膨脹,且由于Mesh不同,會影響到渲染的合批(URP是否有影響待考),影響性能。從另一方面說,烘焙好的表面貼圖靈活性也不夠,不適合有強(qiáng)烈的動態(tài)的明暗變化的場景,所以綜合考量下來,我們決定棄用Lightmap。

2.0 總體GI方案

使用實(shí)時(shí)計(jì)算的直接光照和陰影,再輔以lightProbe補(bǔ)全間接光照。

2.1 切割場景

鑒于超大地圖的特效,需要分場景烘焙,建議拆分出的子場景地塊邊長相等且場景與場景之間大小也相等,能給予以后管理和加載數(shù)據(jù)不少便利。以當(dāng)前DEMO為例,其Base場景在加載余下9塊子場景后,可以視為一個(gè)9宮格,每個(gè)宮格都是一塊正方形平面外加4個(gè)幾何物件組成。

Whole Scene.png

如下圖所示,一塊拆分好的子場景占示例場景的1/9:

Single Scene.png

在當(dāng)前示例中,場景由全局主場景Base和9個(gè)子場景Test_0 ~ Test_8構(gòu)成:

Asset View.png

關(guān)于場景上物件,一般情況下所有靜態(tài)物體的Mesh Renderer組件中需要勾選 Contribute GI,不過決定哪個(gè)物體要貢獻(xiàn)GI哪個(gè)不要的應(yīng)該是美術(shù)同學(xué),而且最好是它們在構(gòu)建模型的時(shí)候就打上Tag,當(dāng)導(dǎo)入U(xiǎn)nity后由腳本識別Tag,自動同步到烘焙屬性設(shè)置去;另一方面因?yàn)槲覀儾恍枰猯ightmap,可以在Receive GI下拉欄中,選擇Light Probes(而不是lightmap),這樣做可以一定程度的加速烘焙。

Mesh Renderer.png

2.2 布置探針

場景拆分完成后我們需要找一個(gè)空場景作為烘焙用主場景,然后把所有子場景拖入Base Scene(以Additive模式追加打開場景)

Base Hierarchy.png

需要注意的是,主光源只保留一份即可,建議使用主場景中的方向光作為主光源,刪除或者失活子場景中相對應(yīng)的方向光。當(dāng)上述準(zhǔn)備完成,接下來才是布置探針,示例DEMO中探針是布置在主場景中的,因?yàn)榇姹鹤訄鼍笆且訟dditive模式打開的,光照烘焙時(shí)探針對象被放置在哪個(gè)場景并不影響烘焙效果。

放置探針有一些比較通用的建議,比如:不要將探針放置在物體內(nèi)部,不要將所有探針放置在一個(gè)平面上,在稀疏空間上可以少布置探針,光照變化豐富的地方最好多布置探針等等。但是不論如何,面對超大地圖,最好還是采用腳本布設(shè)+手工后期調(diào)整的方式比較靠譜。這里推薦一些工具供大家參考:

2.3 烘焙 x N

DEMO場景比較簡單,所以直接手K了一組Light Probe Group,我們以烘焙9宮格左下角子場景為例,擺放好探針的效果如圖

Scene Probes.png

可以看到,我們的探針覆蓋了這塊子場景的全部區(qū)域還有余量,這很好理解,因?yàn)槲磥碓谶\(yùn)行時(shí)我們會按照角色是否踏入子場景地塊邊界來決定替換新舊探針組,留一些余量可以減少這種探針切換時(shí)帶來的可能的環(huán)境光跳變,同時(shí)也為我們做“延后切換”提供了數(shù)據(jù)保障。
在按下Generate Lighting按鈕前,我們還有一件非??梢宰龅氖虑椋菏Щ畹舢?dāng)前光照探針沒有觸及到的場景,如下圖所示:

Scene Probes ready.png

可以想象,這些被unload的場景對當(dāng)前光照探針的貢獻(xiàn)微乎其微,將其暫時(shí)卸載以提高光照烘焙的速度,降低烘焙時(shí)的內(nèi)存開銷,才能使得大場景烘焙成為可能。只不過這樣的單場景烘焙要執(zhí)行N次,需要反復(fù)加載和卸載一些場景,所以建議工程化后用腳本替代這些手操比較好。

2.4 導(dǎo)出lightProbes.Asset

當(dāng)每烘焙完成一個(gè)子場景,都要即時(shí)從LightmapSettings中導(dǎo)出并保存探針數(shù)據(jù):

AssetDatabase.CreateAsset(Instantiate(LightmapSettings.lightProbes), defaultPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();

導(dǎo)出后保存為Asset資源,以地塊編號輔助命名:

Baked Probe Assets.png

這些保存的Asset資源分別記錄了每次光照烘焙得到的探針數(shù)據(jù),可以用記事本打開查看其中數(shù)據(jù):

%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!258 &25800000
LightProbes:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  m_Name: lightProbe_0
  m_Data:
    m_Tetrahedralization:
      m_Tetrahedra:
      - indices[0]: 47
        indices[1]: 8
        indices[2]: 19
        indices[3]: 17
        neighbors[0]: 114
        neighbors[1]: 93
        neighbors[2]: 148
        neighbors[3]: 132
        matrix:
          e00: 0.07278019
          e01: 0
          e02: 0
          e03: 0
          e10: -0
          e11: -0
          e12: -0.07358351
          e13: 0
          e20: -0.0727802
          e21: -0.16666667
          e22: -0
          e23: 0
          ...
    m_ProbeSets:
    - m_Hash:
        serializedVersion: 2
        Hash: a59bcf93405ea129891b07a70413d3af
      m_Offset: 0
      m_Size: 72
    m_Positions:
    - {x: -17.24, y: 8, z: -46.59}
    - {x: -17.24, y: 8, z: -16.67}
    - {x: -17.24, y: 2, z: -46.59}
    - {x: -17.24, y: 2, z: -16.67}
    - {x: -17.24, y: 8, z: -27}
    ...
  m_BakedCoefficients:
  - sh[ 0]: 0.2021866
    sh[ 1]: -0.027633084
    sh[ 2]: 0.0001353545
    sh[ 3]: 0.014740911
    sh[ 4]: -0.004248155
    sh[ 5]: -0.0082279835
    sh[ 6]: 0.010135704
    sh[ 7]: -0.015571148
    sh[ 8]: 0.04651142
    sh[ 9]: 0.27547473
    sh[10]: -0.02017048
    sh[11]: -0.016495945
    sh[12]: 0.062124733
    sh[13]: -0.025204908
    sh[14]: -0.0073637315
    sh[15]: 0.010615408
    sh[16]: -0.06704397
    sh[17]: 0.05697039
    sh[18]: 0.31635872
    sh[19]: 0.05582881
    sh[20]: -0.00720126
    sh[21]: 0.009610832
    sh[22]: 0.008846933
    sh[23]: -0.034047075
    sh[24]: 0.018298429
    sh[25]: 0.002981733
    sh[26]: 0.035521053
    ...
  m_BakedLightOcclusion:
  - m_ProbeOcclusionLightIndex: ffffffffffffffffffffffffffffffff
    m_Occlusion:
    - 0
    - 0
    - 0
    - 0
    ...

除去頭部的基礎(chǔ)信息外,數(shù)據(jù)主要分為4個(gè)部分:

  • 第一部分m_Tetrahedralization記錄了四面體網(wǎng)絡(luò),以便運(yùn)行時(shí)快速定位熱點(diǎn)區(qū)域;
  • 第二部分是m_Positions,自然是存放每個(gè)Probe的空間位置信息;
  • 第三部分是m_BakedCoefficients,既存放了我們通常認(rèn)識中LightProbe應(yīng)該存放的球諧系數(shù),每個(gè)共3*9=27個(gè)浮點(diǎn)數(shù)值;
  • 最后一部分叫m_BakedLightOcclusion,存放的可能是一些遮擋關(guān)系,方便Unity做快速剔除用(待考)。

3.1 分地塊(Tile)動態(tài)加載

我們按照地塊(Tile)生成LightProbes資源,在運(yùn)行時(shí)則依據(jù)角色/攝像機(jī)當(dāng)前所屬地塊,動態(tài)的加載 -> 替換光照探針資源。具體算法可以參考以前介紹過的9宮格系統(tǒng),默認(rèn)當(dāng)前攝像機(jī)處于中心,需要提前異步得加載周圍8塊地塊對應(yīng)的LightProbes資源,確保當(dāng)需要切換資源時(shí),資源總是在手邊可用。

具體切換操作非常簡單,如下:

LightmapSettings.lightProbes = usedLightProbes[curIndex];

只需要將預(yù)加載好的對應(yīng)場景LightProbes對象替換全局對象LightmapSettings的lightProbes變量即可,無需添加或切換場景。

3.2 地塊銜接處的處理

細(xì)化一下切換LightProbes資源邏輯,既所謂的“延后切換”:為了避免因?yàn)榻巧诘貕K交界處來回移動,而頻繁觸發(fā)切換操作的弊端,我們規(guī)定只有當(dāng)角色越過了原本地塊邊界一定距離(gap)后才會觸發(fā)刷新和切換,只要觸發(fā)切換時(shí)角色所在位置任然被上一個(gè)場景的LightProbes覆蓋,就不會導(dǎo)致環(huán)境色跳變,同時(shí)由于增加了gap,角色來回運(yùn)動時(shí)必須要滿足 2 x gap 的距離才會再次觸發(fā)切換,從而降低了頻率。

3.3 TOD和探針的明暗變化

Time of Day(簡稱TOD)要求游戲光照能夠隨時(shí)間變化而變化,以適應(yīng)不同時(shí)間段天光輻照度的要求,例如表現(xiàn)黎明時(shí)分的醬紫色或日落時(shí)刻的橙紅色。為了達(dá)到目的,我們首先想到的是由天空盒控制主光源(日光)的光強(qiáng)和顏色,但是這只影響直接光照,間接光照來自預(yù)烘焙的光照探針,而烘焙時(shí)日光使用了什么強(qiáng)度/顏色,探針中就存儲了對應(yīng)的間接光漫反射信息。

如果不考慮代價(jià),為達(dá)到最真實(shí)效果,我們需要在每一次日照發(fā)生變化時(shí)都烘焙一份探針信息,運(yùn)行時(shí)排著隊(duì)的替換使用。

從另一方面考慮,如果要求代價(jià)最小,那么我們可以只烘焙一份探針信息,運(yùn)行時(shí)只簡單調(diào)整探針光照信息的強(qiáng)度。

最后平衡一下這兩種極端情況,我們可以烘焙若干個(gè)有代表性的時(shí)間點(diǎn),運(yùn)行到下一個(gè)時(shí)間段才觸發(fā)光照探針的替換,銜接的部分則可以通過球諧系數(shù)或者光照強(qiáng)度的調(diào)節(jié),盡可能讓2套探針的光照信息在替換的時(shí)刻保持一致,從而減小視覺上的跳變感。

3.4 LightProbes 強(qiáng)度設(shè)置注意點(diǎn)

本節(jié)以調(diào)節(jié)探針光照強(qiáng)度為例,一起來看看Unity為用戶暴露出了哪些接口。首先我們可以在:

LightmapSettings.lightProbes.bakedProbes

中獲取到一組探針信息隊(duì)列,隊(duì)列中的元素是一種叫 SphericalHarmonicsL2 的類,意思是二階球諧函數(shù),存儲了來自L0的1個(gè),來自L1的3個(gè)以及來自L2的5個(gè),一共1 + 3 + 5 = 9 組系數(shù),每組系數(shù)都需要代表一種RGB色彩,所以系數(shù)總合還要再乘以3個(gè)通道,最終得到 3 * 9 = 27個(gè)浮點(diǎn)數(shù)。 我們在訪問類 SphericalHarmonicsL2 時(shí),可以采用如下方法獲取到這27個(gè)球諧系數(shù):

for (int j = 0; j < 3; j++)
{
    for (int k = 0; k < 9; k++)
    {
        Debug.Log(sh[j, k]);
    }
}

特別注意一點(diǎn),雖然Unity允許我們通過下標(biāo)的方式直接訪問甚至修改這些球諧系數(shù),但是至少在Unity2019版本上,這種修改方式不會在運(yùn)行時(shí)產(chǎn)生任何效果,這與網(wǎng)上的大部分參考示例(詳見參考2、參考3)顯示的結(jié)果不一致!推測原因是Unity在某個(gè)版本之后關(guān)閉了直接手K系數(shù)的通道,取而代之的是一些新的API。

說到API,因?yàn)樾薷墓庹仗结様?shù)據(jù)主要就是修改SphericalHarmonicsL2,我們再來看看這個(gè)類有哪些接口:

public struct SphericalHarmonicsL2 : IEquatable<SphericalHarmonicsL2>
    {
        public float this[int rgb, int coefficient] { get; set; }
        public void AddAmbientLight(Color color);
        public void AddDirectionalLight(Vector3 direction, Color color, float  intensity);
        public void Clear();
        public override bool Equals(object other);
        public bool Equals(SphericalHarmonicsL2 other);
        public void Evaluate(Vector3[] directions, Color[] results);
        public override int GetHashCode();
        public static SphericalHarmonicsL2 operator +(SphericalHarmonicsL2 lhs,  SphericalHarmonicsL2 rhs);
        public static SphericalHarmonicsL2 operator *(SphericalHarmonicsL2 lhs,  float rhs);
        public static SphericalHarmonicsL2 operator *(float lhs,  SphericalHarmonicsL2 rhs);
        public static bool operator ==(SphericalHarmonicsL2 lhs,  SphericalHarmonicsL2 rhs);
        public static bool operator !=(SphericalHarmonicsL2 lhs,  SphericalHarmonicsL2 rhs);
    }

Unity給的官方示例里使用的是 AddAmbientLightAddDirectionalLight 這兩個(gè)接口,它們分別提供了在運(yùn)行時(shí)動態(tài)設(shè)置Ambient和DirectionLight這兩項(xiàng)參數(shù)的能力,但是個(gè)人感覺不適合我們預(yù)想的應(yīng)用場景,因?yàn)閺脑O(shè)置參數(shù)到轉(zhuǎn)化為27個(gè)系數(shù)需要比較復(fù)雜的數(shù)學(xué)運(yùn)算,這是一處額外的CPU消耗,且我們也無法簡單準(zhǔn)確地給出每一個(gè)探針點(diǎn)的對應(yīng)環(huán)境光和方向光,這些參數(shù)本身應(yīng)當(dāng)是預(yù)計(jì)算結(jié)果,不應(yīng)在運(yùn)行時(shí)現(xiàn)場運(yùn)算,消耗更多的計(jì)算資源。

余下比較有意思的是2個(gè)算符重載,一個(gè)是“*”一個(gè)是“+”。

先說星號,經(jīng)過測試,發(fā)現(xiàn)參與運(yùn)算的浮點(diǎn)數(shù)起到了控制探針光強(qiáng)的作用,變化是線性的,乘子為0則全黑,乘子為1則維持本色。

其次是加號,可以猜想是通過某種算法,將參與相加的兩組球諧函數(shù)系數(shù)混淆起來,這點(diǎn)比較有意思,因?yàn)槲覀兛梢岳眠@種簡單的加法混淆,將一種SH狀態(tài)漸漸的轉(zhuǎn)化到另一種SH狀態(tài),從而完成烘焙數(shù)據(jù)之間的無縫轉(zhuǎn)換。

為簡單起見,這里先用星號算符對lightProbes進(jìn)行強(qiáng)度控制,代碼參考如下:

void Start()
{
  ...
  LightmapSettings.lightProbes = Instantiate(originLightProbes[curIndex]);
}

void Update()
{
  ...
  var bakedProbes = LightmapSettings.lightProbes.bakedProbes;
  var origin = originLightProbes[curIndex].bakedProbes;
  for (int i = 0; i < bakedProbes.Length; i++)
  {
      bakedProbes[i] = origin[i] * intensity;
  }
  LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}

這里需要強(qiáng)調(diào)一點(diǎn),就是任何對bakedProbes的修改,都會影響到Asset資源的數(shù)值,所以務(wù)必只對實(shí)例化后的bakedProbes進(jìn)行修改,參考Start方法中調(diào)用的實(shí)例化函數(shù)。另外不難發(fā)現(xiàn),在Update方法中 bakedProbes[i] = origin[i] * intensity; 使用的乘法算子已經(jīng)是被Unity重載過的了。

4.1 優(yōu)化

上述實(shí)踐完成后,我們已經(jīng)有了一套初步的GI方案來適配TOD,但是通過控制探針光強(qiáng)的方法過于簡單和缺乏靈活性。舉個(gè)例子,只控制光強(qiáng)因子的前提下,我們會烘焙一套正午時(shí)分的環(huán)境光資源,然后設(shè)計(jì)一條類似正弦曲線,讓光強(qiáng)因子在黎明時(shí)分從0開始增長,直到正午達(dá)到峰值1,然后緩慢落回0,此時(shí)正值夜幕降臨。這種設(shè)計(jì)也許能對付下簡單的晝夜交替的變化,但是當(dāng)遇到諸如在夜晚發(fā)光螢石或者光源,那么周圍的環(huán)境光就會穿幫(此時(shí)環(huán)境光強(qiáng)為0)。一種可行的解決辦法是預(yù)先烘焙多組探針資源,然后設(shè)法在它們之間順滑地切換,后續(xù)我們將基于這個(gè)假設(shè)來討論如何優(yōu)化表現(xiàn)效果。

優(yōu)化的另一方面是性能開銷,在保證效果的前提下,我們會討論如何降低計(jì)算復(fù)雜度,降低內(nèi)存開銷等影響幀率的部分。

4.2 效果優(yōu)化

要實(shí)現(xiàn)在兩套資源 lightProbesAlightProbesB 間順滑過度,無論一開始基于哪一個(gè)lightProbes,只通過調(diào)節(jié)其光照強(qiáng)度是無論如何也做不到順滑切換的,就像你不可能通過調(diào)整一把紅色手電筒的強(qiáng)度,自然過度到綠色手電筒的效果(前提是切換點(diǎn)兩者的光強(qiáng)都不是0)。那么什么方法可以呢?答案是按比例插值。假設(shè)我們一開始基于lightProbesA的數(shù)據(jù)顯示環(huán)境光,首先我們想辦法預(yù)計(jì)算兩道資源的差異:
delta = lightProbesB - lightProbesA
然后我們還需要一個(gè)系數(shù)來控制插值的百分比:
intensity = (cur_time - start_time_A) / (start_time_B - start_time_A)
那么最后過渡段某個(gè)時(shí)刻的環(huán)境光插值可以作如下表示:
usedLightProbe = lightProbesA + delta * intensity
這里面使用到的‘+’和‘*’都是Unity重載過的算符
具體到Unity工程中,簡化后的計(jì)算delta的代碼可以參考如下:

delta = new SphericalHarmonicsL2[probes[0].count];

for (int i = 0; i < probes[0].count; i++)
{
    SphericalHarmonicsL2 shA = probes[0].bakedProbes[i];
    SphericalHarmonicsL2 shB = probes[1].bakedProbes[i];

    SphericalHarmonicsL2 d = new SphericalHarmonicsL2();

    for (int j = 0; j < 3; j++)
    {
        for (int k = 0; k < 9; k++)
        {
            d[j, k] = shB[j, k] - shA[j, k];
        }
    }

    delta[i] = d;
}

而使用插值修改lightProbes可以參考如下示例:

public void ChangeProbes()
{
    float intensity = (Mathf.Sin(Time.time / 2.0f) + 1f) / 2.0f;  // 0 ~ 1

    var bakedProbes = LightmapSettings.lightProbes.bakedProbes;

    var originProbes = probes[0].bakedProbes;

    for (int i = 0; i < bakedProbes.Length; i++)
    {
        bakedProbes[i] = originProbes[i] + delta[i] * intensity;
    }

    LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}

4.3 分幀更新

之前提及過,修改每個(gè)光照探針只涉及到27個(gè)浮點(diǎn)數(shù)的加法和乘法操作,且由于我們區(qū)分了地塊,每次只工作在一個(gè)相對小規(guī)模的光照探針網(wǎng)絡(luò)中,奈何光照探針的數(shù)目很可能任然巨大,如果每一幀都要執(zhí)行數(shù)千上萬次乘法操作,所消耗的CPU資源不可小視。 事實(shí)上,當(dāng)采用上一節(jié)的ChangeProbes方法負(fù)責(zé)刷新探針數(shù)據(jù)并做profiler后得到下圖結(jié)果:

ChangeProbes.png

可見紅色方框內(nèi)產(chǎn)生了數(shù)百次memcpy和算術(shù)操作,此外箭頭標(biāo)記處顯示有大量內(nèi)存Alloc,這個(gè)后面討論。

這個(gè)問題的解決方案很簡單,分幀更新即可,我們每幀不必遍歷所有激活的探針節(jié)點(diǎn),而是把存放探針數(shù)組的容器定義為一個(gè)回環(huán)buffer( 或者叫 ring buffer ),每一次只遍歷從起始位置開始往后的N個(gè)節(jié)點(diǎn),待遍歷完成后再重設(shè)一下起始位置即可。

public void ChangeProbesPartial()
{
    float intensity = (Mathf.Sin(Time.time / 2.0f) + 1f) / 2.0f;

    var bakedProbes = LightmapSettings.lightProbes.bakedProbes;

    var originProbes = probes[0].bakedProbes;

    var totalSize = probes[0].count;

    int ct = MaxNumberPerFrame;
    int i = startIndex;
    while (ct-- > 0)
    {
        bakedProbes[i] = originProbes[i] + delta[i] * intensity;

        i = ++i % totalSize;
    }

    startIndex = i;

    LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}

這樣修改后,更新頻率就變成一個(gè)可控的參數(shù)N。通過Profiler查看結(jié)果如下圖:

ChangeProbesPartial.png

可見紅框中的調(diào)用次數(shù)下降了一個(gè)數(shù)量級,CPU資源消耗幾乎歸零,且顯示效果絲毫沒有影響。

4.4 內(nèi)存優(yōu)化

參考上圖中箭頭處的內(nèi)存消耗(主要來自Alloc)不難發(fā)現(xiàn)Unity底層對LightProbes的get_bakedProbes()操作會產(chǎn)生值拷貝,具體到Unity工程代碼參考如下:

var bakedProbes = LightmapSettings.lightProbes.bakedProbes;   //觸發(fā)get操作
var originProbes = probes[0].bakedProbes;                     //也會觸發(fā)get操作

為了緩解頻繁(每幀)拷貝bakedProbes這種蠢事,我嘗試了一種更加懶惰的更新策略,代碼如下:

 public void ChangeProbesPartialLazy()
{
    float intensity = (Mathf.Sin(Time.time / 3.0f) + 1f) / 2.0f;

    if (startIndex < MaxNumberPerFrame)
    {
        workOn = LightmapSettings.lightProbes.bakedProbes;
    }

    var totalSize = probes[0].count;

    int ct = MaxNumberPerFrame;
    int i = startIndex;
    while (ct-- > 0)
    {
        workOn[i] = origin[i] + delta[i] * intensity;

        i = ++i % totalSize;
    }

    startIndex = i;

    if (startIndex < MaxNumberPerFrame)
    {
        LightmapSettings.lightProbes.bakedProbes = workOn;
    }
}

簡述下邏輯,我們只在完成一次循環(huán)后(完整遍歷了lightProbes隊(duì)列)才設(shè)置 + 獲取一次Unity托管的bakedProbes資源。整個(gè)過程就像打快照一樣,一旦得到快照,在接下來的幾幀或者數(shù)十幀內(nèi)都是基于當(dāng)前快照內(nèi)容進(jìn)行修改,等到快照完成更新后再一次性提交給Unity用來刷新顯示。對于ChangeProbesPartialLazy再次Profiler后得到下圖結(jié)論:

ChangeProbesPartialLazy.png

可以看到,大多數(shù)幀內(nèi)來自方法內(nèi)部的Alloc消失了,并且實(shí)際表現(xiàn)上仍然看不出區(qū)別:

showcase.gif

5.1 測試數(shù)據(jù)

說明:表格展示了隨著烘焙探針數(shù)量的倍增,其資源大小,系統(tǒng)耗時(shí)的成長關(guān)系。
以下所有數(shù)據(jù)取自 WIN7系統(tǒng) Core I7-6700 @ 3.7G 兼容機(jī)平臺 (X2 ~ 2.5 Snapdragon 845 @2018年旗艦)

項(xiàng)目\探針數(shù) 72 144 288 576 1152 2304 4608
Size(KB) 203 428 896 1836 3719 8053 17084
Instantiate Cost (ms) 0.08 0.15 0.25 0.50 1.07 3.00 5.59
Set_Probes Cost (ms) 0.0034 0.0047 0.0069 0.0148 0.0298 0.0533 0.1405

5.2 參考

  1. LightProbe原理和數(shù)據(jù)結(jié)構(gòu)
  2. How to add / update light probes when using load additive
  3. Light Probe Intensity Adjustment Tool for Unity3D
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容