Unity Built-In Shader造成的运行时内存暴涨

时间:2024-03-01 12:36:27

  在某个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, 不过一个程序天空盒就要编译几十毫秒, 注意一下还是很香的...