Unity Built-In Shader造成的運行時內存暴漲


  在某個PC項目中使用了大量的材質球, 並且都使用了自帶的Standard Shader, 在編輯器運行的時候, 一切良好, 運行內存只在1G左右, 然而在進行AssetBundle打包之后, EXE運行內存暴漲至20G都還沒進入場景, 簡直不可思議.

PS: 所有測試都在 Unity5.6 / Unity2017.3 / Unity2018.3 / Unity2019.2 中測試過

  看SVN美術人員添加了SpeedTree, 各種花草樹木, 考慮到是不是shader的變體過多導致的shader編譯問題, 就先把所有Nature開頭的built-in shader加入到GraphicSettings的AlwaysIncludedShaders里面去

  這樣加了之后可以打開場景了, 運行內存仍然飈到12G......developerment build 連接到Profiler查看, 光是ShaderLab就占了5.7G... 這樣看來果然是shader造成的了, 然后檢查一下Asset中各個Shader的資源情況發現

相同的Shader沒有重用, 在每個對象上都進行了重復編譯!!! 

(注意這里顯示的shader大小只是引用大小, 實際的編譯大小都算到ShaderLab里面去了)

  猜測該不會是因為運行時的變體編譯混亂導致的每個對象都被認為是唯一的變體, 導致每個物體都進行了編譯呢? 於是在編輯器下運行場景, 然后把所有變體都自動記錄下來, 運行時自動加載試試.

(運行場景之后在運行狀態下創建Preload Shader Variants Collection)

  然而並沒有什么用, 內存還是那樣.

  嘗試把Standard Shader加入到AlwaysIncludedShaders里面去, 結果跟官方說的一樣, 在Build Exe時就卡死了, 幾十分鍾沒反應那種, 這個不能加進去.

  去找Unity官方論壇吧, 也沒人回復, 找到一個老帖子, 里面有說道:

https://forum.unity.com/threads/standard-shader-duplicated-in-asset-bundle-build.593248/

  Unity團隊的這個人叫我們自己把Built-In Shader下載了放到工程里面去, 替換掉原來使用的Standard Shader, 可是為什么? 也不說明原因, 然后后面也沒有什么有用的回復了, 

這不是多此一舉嗎? 所以根據他的說法, 我猜測在使用AssetBundle時, Built-In Shader的封包方式應該跟未命名assetBundleName 的資源一樣, 哪個包需要它, 它就被封到哪個包里去, 

然后在實例化的時候, 直接從那個包里對shader讀取然后編譯, 因為很多材質是被交叉使用的, 很多材質都單獨成包, 所以會造成明明是相同的shader並且變體都一樣仍然被多次編譯的BUG.

  PS : 還是這個人, 說用Addressables

  

  既然這樣猜測了, 那就實測一下吧, 新建一個工程, 拖進去一些建築之類的, 首先測試Built-In 的Standard Shader.

 (場景)(材質, 每個都單獨成包)

  因為材質都單獨成包了, 所以運行時應該Standard Shader應該會每個材質對應一次編譯

(每個單獨包還挺大)

  這里看到了ShaderLab相當大, 並且還真是每個編譯的Standard Shader 對應一個材質... 查看文件的話, 每個材質把Shader封進去了之后, 達到98KB大小, 非常大.

總之就是最坑人的情況 : 文件又大, 運行又占內存, 運行時編譯又花時間!!!

 

  接下來我把所有材質封到一個包里, 這樣理論上來說所有用到Standard Shader 的材質都封一個包, 也就是運行時只會根據變體數量來編譯, 不會進行重復編譯了吧.

 (全都在standard這個包里了)

 (打包后運行跟預想一樣, 感覺沒有重復編譯了)

 (明顯這些同一個包里的材質共用了一個編譯后的Shader)

(所有材質打成的包, 只有206KB)

不信的話使用解包軟件打開看

 

   這樣看來, Built-In Shader 在打包時確實是跟未命名assetBundleName 的資源一樣, 打到了每個需要的包中去了, 造成了各個資源文件的膨脹, 造成了Shader的重復編譯, 以及重復編譯的時間開銷.

類推下來其它的比如 UI, 樹 等如果用到了也會造成同樣結果, 只不過UI使用的Shader比較輕量, 一般不會太過在意, 這次因為項目大量的Nature/Standard Shader被使用引起性能問題才被注意到...

 

  下面測試一下Unity員工說的使用下載來的Built-In Shader替換原Shader的情況, 還是分兩種, 一種每個材質分包, 一種所有材質一個包

 (跟當前版本的Built-In 一樣的Standard, 這個Shader先單獨打包)

 (所有材質替換Shader, 材質單獨打包)

 (打包后運行, 比Built-In 最小時候的6.9MB還小, 暫時不知道原因)

 (對的)

看看它打出的包文件: 

 (Shader文件本身占了97KB)

 (每個材質文件占了2KB)

  結合之前的Built-In Shader每個材質98KB來看, 就是把每個材質跟Shader打成一個包了, 大量重復打包了.

 

再測試一下所有材質封一個包的情況

 (還是所有材質都放standard里)

 (運行時編譯還是這么優秀)

 (同樣的結果)

看看打包后文件

 (Shader文件本身占了97KB)

 (這里更優秀了, 所有材質只占了17KB, 總共只占114KB, 而上面的Built-In 同樣情況占了208KB)

 

PS : 補充最后一種情況, 就是下載來的Built-In Standard Shader不設置包名, 也就是跟自帶的 Standard Shader 一樣的情況, 會怎么樣呢?

 (跟自帶的 Standard Shader 一個樣)

 

  所以可以總結 : 使用系統自帶Shader打包的時候, 因為無法設置Built-In Shader的包名, 所以根據依賴打包會把所有依賴Shader的包都打進相應Shader,

造成資源包變大, 重復編譯, 運行時內存暴漲等問題. 解決方法:

  1. 所有使用到相同Built-In Shader的材質都打到同一個包里......

  2. 下載一份Built-In Shader, 所有資源使用下載來的Shader, 每個Shader如果有多次被引用, 一定要設置包名.

  3. 貌似Unity2019的BuildPipeline 的task還是啥的, 有設置Shader的相關參數, 沒試過...

  歸根結底還是打包依賴的問題, 跟Shader的變體編譯那些都沒有關系.

 

補充一下經過優化后的運行時內存 (啟動時間已經縮短了幾乎70%):

 (ShaderLab從5.7G減少到279MB了)

  之前只是幾百個Standard的編譯就能讓編譯時間飈到3分鍾左右, 十分可怕, 現在整個場景開啟也只要1分鍾.

 運行內存也下來了, 雖然也是突破天際的

 

---------------------------------------

(2020.03.31)

  前面的測試雖然有點效果, 不過目前看來都是不靠譜的, 一個是內置Shader的引用打包問題, 一個是重復編譯的問題, 它們好像並沒有關聯性, 因為即使我把Built-In Shader 下載下來, 復制一個Standard進來然后置換了全部材質, 單獨打包也還是在Shader.Parse使用了很多時間(重復編譯N次), 跟上面的官方人員的回復還不一樣, 繼續跟進.

 (2020.04.02)

  今天把幾個小模型單獨拿出來新建個工程做下測試, 也順便把Built-In Shader下載了放到工程里面, 測試一下到底哪個方法會造成多次編譯. 使用三個模型, 6個prefab, 為了再現一般工程中多個引用的情況.

  里面的MyStandard就是下載的Built-In Standard, MySurfaceShader是最簡單的Lambert光照Shader.

一. 使用內置Standard, Prefab不進行依賴分包, 也就是說每個prefab的包都自動包含所有依賴資源:

  這樣我打包就可以直接讀取prefab打出的包就行了, 每個prefab單獨創建assetbundle:

  運行時每一秒加載一個不同模型, 然后看看讀取的效果, 結果顯示每個模型的讀取都造成了Shader.Parse的結果, 跟前面的推論比較像, 就是說Built-In Shader被引用到不同的包里, 然后在每個包加載的時候單獨進行了編譯(從前面測試的包大小可以看出來):

  看到進行了多次編譯, 其實這3個Shader完全一樣的, 使用的變體一樣, 代碼一樣, 平台一樣, 然后使用依賴打包也是一樣的, 因為依賴資源找不到內置Shader, 所以還是老樣子多次編譯. 如果按照前面的把所有材質都合並在一個AssetBundle里面的話, 它可以合並在一次編譯了, 這是Built-In的特點? 因為UI使用了內置材質(Default UI Material), 都是同一個, 才萬幸沒有這些問題:

 

二. 使用下載的Standard Shader進行測試, 先從依賴打包測試, 全部資源和Shader都成了單獨的包了:

  這樣加載出來的可以合並Shader.Parse了.

  往下再測試一下大一些的場景場景, 原場景使用了內置Standard Shader, 直接替換Shader然后依賴打包 :

    [UnityEditor.MenuItem("Tools/SwitchShader")]
    public static void SwitchShader()
    {
        var curScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene();
        var standard = Shader.Find("Custom/MyStandard");
        if(standard)
        {
            HashSet<Material> materials = new HashSet<Material>();
            foreach(var root in curScene.GetRootGameObjects())
            {
                var mrs = root.GetComponentsInChildren<MeshRenderer>();
                foreach(var mr in mrs)
                {
                    materials.UnionWith(mr.sharedMaterials);
                }
            }
            foreach(var mat in materials)
            {
                mat.shader = standard;
            }
            UnityEditor.AssetDatabase.SaveAssets();
            UnityEditor.AssetDatabase.Refresh();
        }
    }

  然后運行起來加載很久, 打開看到Shader重復編譯了, Standard編譯了N次, 這里場景完全替換了Standard了, 不應該有它才對:

  PS : 沒有文檔, 不知道Shader.Parse的計數跟下面Shader.CreateGPUProgram的計數是什么關系, 按照常理來說Shader.Parse代表加載了多少個Shader文件, 然后下面的Shader.CreateGPUProgram應該是根據得到的變體和平台給GPU提交可以編譯成GPU程序的代碼...

  查看內存中的Shader, 發現剛好有一個Shader有148個引用 :

  然后看到一大堆的Default-Material, 可能就是模型默認材質的問題, 然后Legac Shaders/VertexLit這個這里看是之前就編譯了的吧, 被引用了148次, 它是Standard中的fallback造成的:

    FallBack "VertexLit"

 

  而我修改了全部的材質, 場景里應該不含Standard材質, 數一下下面的一大堆Ref count = 1的材質, 跟148差不多, 然后引用的明顯就是默認材質了:

   然后嘗試一下修改所有的模型去掉自帶材質, 使用重新Import然后觸發AssetPostprocessor的方法:

using UnityEngine;
using UnityEditor;

public class DefaultMaterialStripping : AssetPostprocessor
{
    [MenuItem("Tools/Model/MaterialStripping")]
    public static void ReImportAssetsWithMaterialStripping()
    {
        var fbxs = ShaderTestEditor.GetAllFBX();
        foreach(var fbx in fbxs)
        {
            var modelImporter = ModelImporter.GetAtPath(fbx) as ModelImporter;
            if(modelImporter)
            {
                modelImporter.importMaterials = true;
                modelImporter.importMaterials = false;
                AssetDatabase.ImportAsset(fbx, ImportAssetOptions.Default);
            }
        }
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }
    private void OnPostprocessModel(GameObject model)
    {
        if(model)
        {
            AccessRenderers(model.GetComponentsInChildren<Renderer>());
            AccessRenderers(model.GetComponentsInParent<Renderer>());
        }
    }
    private static void AccessRenderers(Renderer[] renders)
    {
        if(renders != null)
        {
            foreach(Renderer render in renders)
            {
                render.sharedMaterials = new Material[render.sharedMaterials.Length];
            }
        }
    }
}

  這樣很多模型的FBX預覽都變成紫色的了, 先打包看看吧, 結果看到Shader.Parse減少了很多 : 

  看來Shader的重復編譯, 就是模型的默認材質帶來的, 並且它不能正確合並編譯, 導致了讀取模型時的編譯, 在讀取場景的時候就很悲劇了, 現在可以看到兩個Shader.Parse, 而天空盒占了大頭, .

  而這個引用方式在Resources文件夾中的話, 是沒有問題的, 引擎在打包的時候會正確處理並且不會帶入模型的材質, 但是在AssetBundle的時候就算把ImportMaterial去掉, 也是會打包進去了, 這個帶來的不僅是額外的資源, 還是致命的運行開銷.

 

 

 

回頭來看這兩張圖:

  我統計了FBX文件數是146個, 下面的圖完全沒有默認材質的時候是2個Shader.Pass, 也就是說上圖多了每個FBX文件 (應該說每個Prefab對應的原始Mesh) 進行的一次Standard編譯, 而進行了146次Standard的Shader.Parse造成了傳遞到GPU的shader有2628個, 不知道這個數量是不是就是變體的意思, 如果按照平均計算, 每個Standard編譯出了 2628/146=18個, 而MySurfaceShader一次編譯, 有12個. 天空盒也是內置Shader, 也會在加載新場景(未加載過)的時候編譯, 也屬於重復編譯對象.

  接下來按照以前做的提前渲染的方式來試試看能否影響場景加載時的編譯, 就是先把場景中某個Prefab加載到相機前, 然后再加載場景:

  可以看到先加載出來的GameObject進行了Shader.Parse, 並且變體數量18個, 跟前面說的Standard的數量一樣了, 然后在加載場景的時候沒有觸發Shader.Parse :

  只有天空盒, 所以以前比較玄學的提前渲染物體是有道理的, 只不過不是想象的那樣. 

  接下來試試創建一個ShaderVariantCollection來記錄場景中所有變體, 然后提前WarmUp, 再加載場景看看是否能減少Shader.Parse的時間:

    void Start()
    {
        var collection = Load<ShaderVariantCollection>("NewShaderVariants");
        collection.WarmUp();
    }
    float time = 0.0f;  // 協程不好看堆棧, 使用計時器
    private void Update()
    {
        time += Time.deltaTime;
        if(time > 2.0f)
        {
            time = 0.0f;
            LoadScene("XXX", LoadMode.Sync, UnityEngine.SceneManagement.LoadSceneMode.Additive, (_, _scene) =>
            {
                UnityEngine.SceneManagement.SceneManager.SetActiveScene(_scene);
                Debug.Log("Scene Loaded");
            });   
        }
    }

  發現Shader.Parse消失了, 這個確實能省去Shader的編譯時間, 是個好東西啊, 把加載ShaderVariantCollection的代碼去掉, 再試試:

    void Start()
    {
        //var collection = Load<ShaderVariantCollection>("NewShaderVariants");
        //collection.WarmUp();
    }

  它確實又回來了, 用協程計算一下加載ShaderVariantCollection需要的時間:

        var collection = Load<ShaderVariantCollection>("NewShaderVariants");
        var time = Time.realtimeSinceStartup;
        collection.WarmUp();
        while(collection.isWarmedUp == false)
        {
            yield return null;
        }
        Debug.LogError("WarmUp Time : " + (Time.realtimeSinceStartup - time));

  一幀都不用, 跟直接編譯MyStandard的2ms比起來太有優勢了.

  然后把程序化的天空盒代碼也放到項目中, 創建天空盒材質, 把變體保存到NewShaderVariants里面, 再運行一次:

  現在場景加載完全不包含Shader.Parse了, 總加載時間正好是前面的800多毫秒減去Shader編譯的60多毫秒. 並且ShaderVariantsCollectoin的加載幾乎不用時間...

  再來看一下以前比較玄學的 [用相機照到實例化物體才會編譯Shader] 的事件:

    IEnumerator LoadPrefab()
    {
        var cam = Camera.main;
        cam.enabled = false;
        yield return new WaitForSeconds(1.0f);

        var prefab = Load<GameObject>("Prefabs/AFC_01");
        Debug.LogError("Prefab");  
        yield return new WaitForSeconds(1.0f);

        var go = GameObject.Instantiate(prefab);
        go.transform.position = Vector3.up * 100.0f;
        Debug.LogError("GameObject");
        yield return new WaitForSeconds(1.0f);

        cam.enabled = true;
        go.transform.SetParent(Camera.main.transform);
        go.transform.localPosition = Vector3.forward * 10.0f;
        Debug.LogError("END");
    }

  先關了相機, 等一會讀取資源, 再等一會實例化資源, 在等一會打開相機, 把實例化對象放到相機前面...

  然后看到實際在加載資源完成后Shader就被編譯了, 並不需要相機看到它的實例才行...

  為了測試一下Shader的編譯是否是全局的了, 通過加載一個GameObject再卸載, 再加載第二個GameObject的方式來測試一下, 因為是AssetBundle模式, 所以使用了卸載資源和包的方式:

    IEnumerator LoadPrefab()
    {
        var cam = Camera.main;
        cam.enabled = false;
        Debug.LogError("Start");
        yield return new WaitForSeconds(1.0f);

        var prefab = Load<GameObject>("Prefabs/AFC_01");  // 不能Destroy
        prefab = null;
        UnloadAsset<GameObject>("Prefabs/AFC_01");    // 這里調用了AssetBundle.Unload和Resources.UnloadUnusedAssets接口
        Debug.LogError("Prefab");  
        yield return new WaitForSeconds(2.0f);
    
        var prefab2 = Load<GameObject>("Prefabs/BILLBOARD_005");
        Debug.LogError("Prefab2");
        yield return new WaitForSeconds(1.0f);
        
        cam.enabled = true;
        Debug.LogError("END");
    }

 

  第一個資源的依賴AssetBundle全卸載了, 並調用資源清理接口, 結果第二個GameObject加載沒有觸發Shader.Parse, 應該是全局編譯的了.

 

  總結一下:

  1. Built-In Shader在AssetBundle模式下, 是會粘包的, 就是被打包到每個引用它的材質AssetBundle里, 造成包變大且多次編譯的錯誤, 導致運行時致命的編譯時間, 所以使用AssetBundle的情況要把內置Shader下載來放到工程里使用, 避免這種情況. 並且經過測試ShaderVariantsCollection記錄的變體對它無效, 仍然被強行編譯了.

  2. 模型導入時的默認材質, 也是Standard的, 即使設定Import Materials為false仍然在AssetBundle打包的時候會自動包含在包里, 在讀取資源完成的時候就自動創建出來了, 跟1的情況一樣, 導致包變大多次編譯, 所以模型導入后要創建給Prefab用的新材質, 把默認材質刪除掉.

  3. ShaderVariantsCollection很強大, 用好了直接省了Shader編譯了.

  4. 雖然很少項目直接用內置Shader, 不過一個程序天空盒就要編譯幾十毫秒, 注意一下還是很香的...

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM