一种Shader变体收集和打包编译优化的思路
一、什么是变体
引用Unity官方文档的解释:ShaderVariant
In Unity, many shaders internally have multiple "variants", to account for different light modes, lightmaps, shadows and so on. These variants are indentified by a shader pass type, and a set of shader keywords.
Unity的Shader资源不仅含有GPU上执行的着色器代码,还包含了渲染状态,属性的定义,以及用于对应不同渲染管线不同渲染阶段的着色器代码,每一小段代码也可能会有不同的编译参数,以对应不同的渲染功能。
在带有多个变体的Shader代码片段中,最显著的特征就是拥有这些预编译开关,如:
#pragma multi_compile_fwdbase // unity 内置前向管线编译设置集合,控制光照,阴影等很多相关功能
#pragma shader_feature _USE_FEATURE_A // 自定义功能开关
#pragma multi_compile _USE_FUNCTION_A _USE_FUNCTION_B // 自定义多编译选项
有了这些编译开关标记,我们就可以写很少的Shader代码,从而依附在这份骨架代码上,来实现含有细微差异功能的变种Shader代码。当然功能越多,这些变体数量也成指数级增长,如何控制这些变体可能产生的数量,也需要较为丰富的经验和技巧。
二、为什么要收集变体
游戏初始化的时候,一般需要提前把渲染要使用的Shader全部都加载进来,以降低游戏运行时及时加载和编译带来的卡顿,这时候我们可以调用Shader.WarmupAllShaders来把当前已经加载到内存的Shader全部编译一次,包含所有的变体。
随着项目渲染效果的丰富,Shader变体变得越来越多,粗暴的调用全加载接口,会导致游戏的启动时间变得更长,影响游戏体验。
后来Unity进入了变体集合ShaderVariantCollection来取代上面的粗暴全加载接口,达到按需加载,提高加载速度。
官方解释中最为关键的内容如下:
This is used for shader preloading ("warmup"), so that a game can make sure "actually required" shader variants are loaded at startup (or level load time), to avoid shader compilation related hiccups later on in the game.
也就是说,变体记录的是游戏实际上使用到的变体集合,那么对其进行按需加载能够大大提高游戏加载速度。
三、其它一些理解
从官方文档上我们得知,变体集合是用于预加载Shader,但是并没有提及打包发布过程中的编译,以及如何筛选实际使用的编译进入AssetBundle。
在实际发布的游戏包里的Shader资源,如果缺失了需要的某个变体,那么可能得到的渲染结果就不正确了(Unity在加载带有多个变体的时候,应该是使用了某种匹配方法,如果没有找到最佳匹配,它就会fallback到另外keyword匹配数量最多的变体,从而渲染出有部分差异或者甚至完全错误的结果)。最可气的是,你在实际运行设备上是看不到变体缺失的精准信息的,一旦出错,你可能只能全盘再来。
Shader变体丢失通常都由与AssetBundle的分包打包导致。Unity内部搜集实际使用的变体是通过扫描使用Shader的材质,以及场景渲染器上的光照参数来综合获取(应该就是把内部渲染管线中,实际使用的Shader变体记录下来)。为了实现AssetBundle的更新,我们通常会把Shader作为单独的资源放在一个独立的AssetBundle中,而其它引用这些Shader的材质和场景,将作为AssetBundle的依赖加载。Shader一旦脱离了使用它们的载体,Unity在打包的时候无法全盘考虑哪些变体需要实际发布,从而随机性的出现恼人的变体丢失现象。
网上的一些解决变体丢失办法:
(1)把Shader和使用它们的材质一同打到一个AssetBundle;
(2)在Editor通跑一遍整个项目场景,Unity会把搜集到的所有Shader以及变体记录下来,然后把这个信息保存成变体集合,把它和Shader一并打包。
Unity在Project Settings面板最下面隐藏了这个最为重要的功能:
上面提到的方法从多数情况下都能正常工作,但是:
- 方法(1)中,光从材质上不能获得完整的变体使用记录,还和实际渲染器和所在场景的全局光照有关系。
- 方法(2)中,人为搜集始终有漏网之鱼,而且Unity保存出来的Shader变体全在一起,如果不经过拆分,打包分包策略会有些许影响。
四、我的解决办法
1、项目中的用于渲染的资源一般来说只有三大类
(1)场景
(2)动态加载的模型,角色,特效等
(3)UI & UIEffect
其中,一般UI都是直接使用UGUI内置的着色器,变体都是通过multi_compile提供,这种编译开关可以保证无论此开关在材质中是否使用,变体都会得到编译并且经入发布真机包,并且用于UI的Shader不会太多,我们就简单处理了。
故最终我们只需要考虑Shader的两种使用情形:场景中动态加载和场景静态资源。
2、实现一个自动Shader变体收集器,步骤如下:
(1)把当前需要打包的资源路径搜集起来(按照工程发布设置,如:多语言、渠道);
(2)通过依赖关系,把动态加载的Prefab这类资源依赖的材质路径搜集起来;
(3)打开一个新的空场景,创建一个游戏场景中的动态光源环境,如:实时平行光;
(4)反射调用ShaderUtil.ClearCurrentShaderVariantCollection清空当前项目搜集到的变体,我们需要重新搜集一次;
(5)场景中创建一个用于渲染的相机;
(6)在场景中创建一堆sphere几何体,并排列整齐,然后把渲染相机对齐它们,并保证它们都可以看见;
(7)分批次把这些材质资源赋予这些sphere几何体,渲染一帧;
(8)渲染完毕之后,依次打开场景,并且设置好一个全景相机视角并渲染;
(9)这样基本上项目上的Shader变体已经搜集完毕,反射调用ShaderUtil.SaveCurrentShaderVariantCollection保存到一个整体变体集合资源中去;
(10)自动搜集工具任务完成。
3、有了这个变体集合,就OK了吗?
不,任务只完成了一半。有些自定义的Shader,尤其是那些只通过UsePass被引用到的Shader,并没有出现在任何材质资源上,故不能被Unity搜集到它们的变体(这一点我肯定。我们实际项目中,使用了一些多Pass的Shader,这些Pass都通过UsePass包含那些未开放给美术使用的内部Shader提供,美术直接使用的Shader是没有实际代码的。这样做的好处是有更大的灵活性来组合更多的多Pass组合Shader,而不增加代码量)。
举个例子,有三个Shader:
- ABC.shader
- InternalA.shader
- InternalB.shader
ABC使用了InternalA、InternalB的内部pass,ABC并没有实际的代码片段。这种情况下,Unity搜集到的变体集合,是属于ABC的,而没有分别区分InternalA、InternalB,如果你直接拿这份Unity的导出结果,很有可能导致变体丢失。
我们需要把Unity导出的变体集合挨个拆分成零散资源,一来可以创建那些被关联进来的Shader变体集合,二来也可以方便打包粒度拆分。
4、继续
在继续之前,先做一些准备工作:
(1)通过反射ShaderUtil.OpenShaderCombinations(shader, usedBySceneOnly = true)可以打开一个Unity生成的Library/ParsedCombinations-xxx.shader文件,通过文本解析,可以得到所有有效的builtin、shader_features、multi_compiles这三大类keyword,以及代码snippets标记;
(2)通过反射方法读取ShaderVariantCollection中每一组Shader的变体集;
(3)做好一些缓存工作,便于后期重复获取这些信息。
为了让下面的逻辑表述更为清晰,用伪代码表示:
// 开始拆分总集,并为所有的shader创建独立变体集
ShaderVariantCollection unityVAC; // unity导出的总集
foreach ( curSVC in unityVAC ) {
// 集合中当前一个子集shader
var cur_shader = curSVC.shader;
// 当前shader的所有变体
var cur_shaderVariants = curSVC.variants;
// 为当前创建新的独立变体集
var va = new ShaderVariantCollection();
// 尝试把这些变体拷贝到新的变体集中去
foreach ( cur_v in cur_shaderVariants ) {
try {
var realSV = new ShaderVariantCollection.ShaderVariant( cur_v.shader, cur_v.passType, cur_v.keywords );
va.Add( realSV );
} catch ( ... ) {
// 说明此变体不属于当前创建的shader的指定pass类型
// 走到这里,一般都因unity搜集的变体属于依赖项
}
}
Save( va );
// 获取依赖,通过UsePass, Fallback进来的
// 依次为子shader创建或更新变体集
var child_shaders = GetDependencies( GetAssetPath( cur_shader ) );
foreach ( child_shader in child_shaders ) {
// 被依赖shader可以被多个不同shader多次依赖,这里要注意缓存
var child_va = TryGet_New_ShaderVariantCollection( child_shader );
// 把变体依次传入测试是否同时属于依赖child_shader
foreach ( cur_v in cur_shaderVariants ) {
var _keywords = copy( cur_v.keywords );
// 此变体中可能含有不属于child_shader的关键字
// 通过之前提供的解析ParsedCombinations文件,排除它们
RemoveInvalidKeyword( _keywords, child_shader );
try {
var realSV = new ShaderVariantCollection.ShaderVariant( child_shader, cur_v.passType, _keywords );
// 注意去重
if ( !child_va.Contains( realSV ) ) {
child_va.Add( realSV );
}
} catch ( ... ) {
// ...
}
}
}
// 保存所有的被依赖进来的shader的变体集合
// ...
// 这里有一些问题:
// 1\. 由于变体的从属pass不能获取完整,
//(既没有passName,也没有passIndex)
// 所以不能精准地为依赖项创建变体,所以上面代码中只要变体合法,就当他使用了
// 2\. 一个shader既可以直接被材质使用,被Unity搜集到变体,
// 也可能被其他Shader引用,那么被引用部分产生的变体能否被unity搜集到,
// 还需要进一步测试验证
}
这样经过上面一番折腾,希望能创建最为完整的变体使用记录,开始进行下一阶段折腾。
5、编译时间优化
我发现有了变体集之后,打包Shader时,依然对Shader进行了长时间的编译,就算加上了multi_compile数量的预估,也超过变体集中声明数量太多。由此,我从官方文档对变体集合的解释文字上推断,变体集它只能用于预加载和指定Shader变体的使用子集,至于编译,那是另外一个资源处理阶段,需要我们自行过滤排除。
Unity 2018.2引入了一个可编程的Shader变体移除管道:IPreprocessShaders.OnProcessShader,有了这个接口,我们就可以在Unity编译Shader的时候收到回调通知,我们可以实现自己的Shader变体删除逻辑,进一步减少编译时间。
项目里面可以通过实现多个IPreprocessShaders接口对象,Unity在编译Shader时,会自行创建这些处理器实例,并执行其中的回调接口,我们需要在这些回调中,对传入的参数进行排除。
一个例子:
/// 一个简单排除Unity内建变体编译的处理器
class BuiltinShaderPreprocessor : IPreprocessShaders {
static ShaderKeyword[] s_uselessKeywords;
public int callbackOrder {
get { return 0; } // 可以指定多个处理器之间回调的顺序
}
static BuiltinShaderPreprocessor() {
s_uselessKeywords = new ShaderKeyword[] {
new ShaderKeyword( "DIRLIGHTMAP_COMBINED" ),
new ShaderKeyword( "LIGHTMAP_SHADOW_MIXING" ),
new ShaderKeyword( "SHADOWS_SCREEN" ),
};
}
public void OnProcessShader( Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data ) {
for ( int i = data.Count - 1; i >= 0; --i ) {
for ( int j = 0; j < s_uselessKeywords.Length; ++j ) {
if ( data[ i ].shaderKeywordSet.IsEnabled( s_uselessKeywords[ j ] ) ) {
data.RemoveAt( i );
break;
}
}
}
}
}
我们需要创建更为细致和精准的编译排除逻辑(代码片段不完整)。
class ShaderPreprocessor : IPreprocessShaders {
public void OnProcessShader( Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data ) {
// 跳过处理系统shader,不处理
// return;
// 读取对应shader的变体集:
// 上一步我们为每一个使用到的shader都创建了独立的编译集合
// 获取指定shader的编译信息
var comb = ShaderUtils.ParseShaderCombinations( shader, true );
// 跳过一些完全Use其他shader,自己不含有代码的shader, 不处理
// return;
// 反向遍历,利于删除操作
for ( int i = data.Count - 1; i >= 0; --i ) {
// 当前编译单元中的变体关键字列表
var _keywords = data[ i ].shaderKeywordSet.GetShaderKeywords();
// 只剔除有关键字的情形,减少代码复杂度
// 实际上,无关键字的变体也可能被丢弃不用,简单舍弃这次剔除操作并不会增加太多编译负担
if ( _keywords.Length > 0 ) {
var keywordList = new HashSet<String>();
for ( int j = 0; j < _keywords.Length; ++j ) {
var name = _keywords[ j ].GetKeywordName();
fullKeywords.Add( name );
if ( snippetCombinations.multi_compiles != null ) {
if ( Array.IndexOf( snippetCombinations.multi_compiles, name ) < 0 ) {
// 排除multi_compiles编译宏,这些是必须使用的,不能剔除
// 这里只添加不含multi_compile关键字
keywordList.Add( name );
}
}
}
if ( keywordList.Count > 0 ) {
// 说明这个变体的关键字时可以被剔除编译的
// 进一步判定:
// 由这一关键字序列构成的变体,是否在我们提前存储的变体集资源中出现
// 在遍历判定已经使用的变体集的时候,注意要把含有multi_compile项
// 的关键字去掉,在无序对比,如果能完全匹配,则说明当前次编译的
// shader变体可能会使用,否则就剔除
// ...
var matched = false;
// 遍历所有从项目中搜集到的变体
for ( int n = 0; n < rawVariants.Count; ++n ) {
var variant = rawVariants[ n ];
var matchCount = -1;
var mismatchCount = 0;
var skipCount = 0;
if ( variant.shader == shader && variant.passType == snippet.passType ) {
matchCount = 0;
// 需要说明一下:
// 查找匹配的变体时,需要排除multi_compiles关键字
// snippetCombinations数据从手工解析ParsedCombinations-XXX.shader而来
// 如果直接调用ShaderUtil.GetShaderVariantEntries,可能会因为全变体数量过大而内存爆掉
for ( var m = 0; m < variant.keywords.Length; ++m ) {
var keyword = variant.keywords[ m ];
if ( Array.IndexOf( snippetCombinations.multi_compiles, keyword ) < 0 ) {
if ( keywordList.Contains( keyword ) ) {
++matchCount;
} else {
++mismatchCount;
break;
}
} else {
++skipCount;
}
}
}
if ( matchCount >= 0 && mismatchCount == 0 && matchCount + skipCount == keywordList.Count ) {
matched = true;
break;
}
}
if ( !matched ) {
data.RemoveAt( i );
}
}
}
}
}
}
6、结束
经过上面一系列的操作,Shader变体收集流程和编译时间都得到的优化。但在实现整个了流程的过程中,使用了不少Unity并不常用的编辑器API,由于部分过程获取的信息不完整,导致最终的结果肯定还有一些难以察觉的错误,该方法也需要进一步研究和改进。
封面图来源:Awesome Shaders
一个用CG/ShaderLab编写的用于Unity的着色器集合。
这是侑虎科技第646篇文章,感谢作者卢建供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://github.com/lujian101,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!