|
前段时间在处理项目资源相关工作,发现Unity对Shader的处理与常规资源差异较大。之前对这部分内容了解较少,记录一下学习内容。(不关注Shader具体写法)
一、概述
Shader是运行在GPU上的代码,不同的图形API能够处理的Shader类型不同,下表给出了常见的平台使用的API以及Shader类型。Unity在构建应用时,需要将Shader编译为目标平台能够识别的类型。因此Unity Shader需要有自己的一套逻辑框架(ShaderLab),以便针对特定图形API生成相应的Shader。
图形API | 适用平台 | Shader | DirectX | Window | HLSL | OpenGL | Windows,macOS和Linux等 | GLSL | Vulkan | Windows,macOS和Linux等 | SPIR-V | OpenCL | Windows,macOS和Linux等 | OpenCL C | Metal | macOS和iOS | Metal Shading Language | CUDA | NVIDIA GPU | CUDA C/C++ |
NGFX方案
Unity中常见的Shader作用类型:
- 常用渲染Shader:计算像素颜色
- Computer Shader:利用GPU强大的并行计算能力
- Ray tracing:特定硬件支持光追
二、ShaderLab工作流
2.1 ShaderLab In Editor
ShaderLab Import in Editor
Unity中编写的Shader文件,当Unity对这部分资源导入时,会进预编译将数据缓存到Library\ShaderCache目录下。预处理的结果Shader Compilation Info是一种中间状态的数据,
预处理做了以下几步操作:
- 语法、语义分析
- 切割特定类型Shader代码,例如上面CGPROGRAM ... ENDCG之间的代码。
- 写入本地缓存
2.2 Build
中间资源时无法直接使用的,当我们点击Play或者打包时,存在明确的目标平台,此时Shader Compiler会根据缓存数据,生成真正的Shader资源(变体)。打包时Unity可以对Shader做Strip处理(此处需要小心动态加载的Shader)
2.3 ShaderLab In RunTime
运行时Unity会将Shader资源加载到游戏中,并在运行时动态编译和处理。
ShaderLab In RunTime
在真机环境下,代码调用Warm up或者相关引擎API,进行获取Shader时,会通过Persistent Manager生成标准的Shader Class实例。并且加载Shader资源,反序列化赋值给生成的Shader Class实例。在获取变体时,Unity会对所有Shader变体进行匹配,选择最适合的变体(若目标变体不存在,则使用接近的变体替代)。
Unity完成Shader的Warm Up后,会将CPU端的Shader(变体)拷贝到GPU,并移除CPU端的内存。若Profiler中发现ShaderLab占用过大,大概率是打包了很多变体数据,但并没有被GPU使用。通常项目会使用WormUp将Shader提前加载到GPU。
三、编译与运行
在说明Shade Branching和Shade Variants两个概念前,我们需要先了解一下CPU和GPU逻辑执行层面的差异。GPU采用了数量众多的计算单元和超长的流水线,但只有非常简单的控制逻辑并省去了Cache。而CPU不仅被Cache占据了大量空间,而且还有有复杂的控制逻辑和诸多优化电路,计算能力只是CPU很小的一部分。
当Shader程序中出现条件分支语句时,这会破坏SIMD并行执行的方式,从而降低执行效率。GPU会对这种情况进行优化:将条件分支中的代码简化为只包含一种情况,从而使得不同的线程或片元可以同时执行相同的指令,提高执行效率。这个优化过程被称为“flatten branch”。
3.1 Shade Branching
在介绍逻辑分支前,先了解Shader的一些基础概念。uniform变量可以从CPU传递到GPU的全局变量,它的值在渲染每个物体时都是相同的,比如Unity Shader内置变量UNITY_MATRIX_MVP就是一个uniform。Shader宏可以在编译时定义的预处理指令,以适应不同的平台、渲染质量、特性需求。
// 声明宏:UNITY_EDITOR => 编译产生Shader变体 => 运行时切换开关
#pragma multi_compile UNITY_EDITOR
// 定义全局变量_MyUniform:所有Shader共享
uniform float _MyUniform;
void vert (inout appdata_full v) {
...
#if UNITY_EDITOR
_MyUniform = 0.5;
#else
_MyUniform = 1.0;
#end
}ShaderLab的条件分支分为Static Branching和Dynamic Branching。
Static Branching是指通过宏定义直接对代码进行隔离,编译时会直接将相应的代码排除。通过#if\#ifdef\ #ifndef...#elif...#endif来处理。Static Branching是控制全局的代码分割,避免了逻辑分支的缺点。
以下方示例来说,若开启了Shader宏UNITY_PASS_META则,在渲染时会选择包含其中逻辑的变体进行处理。
void surf (Input IN, inout SurfaceOutput o) {
fixed4 tex = tex2D(_MainTex, IN.uv_MainTex);
fixed4 c = tex * _Color;
o.Albedo = c.rgb;
o.Emission = c.rgb * tex2D(_Illum, IN.uv_Illum).a;
#if defined (UNITY_PASS_META)
o.Emission *= _Emission.rrr;
#endif
o.Alpha = c.a;
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
}
Dynamic Branching是指运行时处理的逻辑分支,这种分支也分为两种类型:基于uniform变量(Shader层面的宏)、非uniform变量。当Shader程序需要访问uniform变量时,只需要在内存中寻址一次,而使用其他变量或常量时,则需要在每个线程或片元的内存中进行寻址,这会带来额外的内存访问和延迟。通常优先使用uniform做逻辑分支。
- 对于非uniform做逻辑分支,GPU需要对两个逻辑分支都进行执行,对结果进行裁剪。uniform做逻辑分支GPU必须flatten the branch
- 无论何种动态分支,GPU必须为其最坏的情况分配寄存空间。如果其中一个分支比另一个有很多的性能消耗,这可能会有性能浪费。
// condition == true
if (condition) {
// branch 1
} else {
// branch 2
}
// 优化代码
branch 1
3.2 Shader Variants & KeyWords
Variants
通常我们直接编写的Shader是Uber Shader,通常包含了多种不同的渲染功能,打包编译时才会产生运行时真正使用的Shader。Uber Shader通常会存在大量的逻辑分支,宏定义的逻辑分支会导致打包时产生Shader Variants (Shader Permutations)。
Shader变体可以理解为Shader多种可能性的坍缩态(Uber Shader有很多逻辑路线,但渲染对象时需要确定的渲染状态,来确定使用哪一个Shader变体),是渲染真正使用的Shader。
- Shader变体可以在着色器程序中使用运行时条件,而不受逻辑分支影响。
- 大量静态分支会导致Shader变体剧增,增加编译时间、Shader内存,严重影响游戏性能。
KeyWords
ShaderLab提供了两种方式来声明Shader宏(uniform):multi_compile和shader_feature。multi_compile声明一组宏,所有声明的宏在编译时都会被处理。下方Shader声明了两组宏,会生成2*3=6种变体。
multi_compile与变体数量关系
shader_feature是multi_compile的子集,在收集变体时会根据资源引用关系,只打包使用到的变体。以下图为例,A、B两个宏使用multi_compile会全部编译,Test材质引用了该Shader只标记了宏E,并且C作为该宏组的默认值也会被编译,所以会生成2*2=4种变体。
shader_feature与变体数量关系
运行时Unity没有宏组的概念,可以对任意key进行赋值,并且赋值时也不会影响其它宏。C#提供了Material和Shader两个层面的宏控制方式,Material只会影响单个材质资源,Shader则会影响所有使用Shader的材质。
public void EnableA()
{
Shader.EnableKeyword("A");
Shader.DisableKeyword("B");
}
public void EnableB()
{
Shader.DisableKeyword("A");
Shader.EnableKeyword("B");
}
还需要注意的是宏定义分为全局(上面的两种方式)和局部的声明(加上_local),全局的宏会影响整个项目,局部只是影响单个Shader。
#pragma multi_compile QUALITY_LOW QUALITY_MED QUALITY_HIGH
#pragma multi_compile_local _ HIT默认情况下,Unity会为Shader每个Stage生成keyword variants。 通常Shader包含顶点着色和片元着色两个阶段,Unity会自动识别并合并相同的变体。若只有特定Stage有宏的需求,这样不会增加构建包体大小,但仍然会影响Shader的编译时间、Shader加载时间以及运行时内存。
为了避免这一问题,可以针对特定Shader Stage进行宏编译,但使用这一功能存在限制:
- OpenGL和Vulkan:在编译时,Unity自动将所有Stage的关键字指令转换为常规关键字指令。
- Metal: 任何以vertex stages的关键字也会影响tessellation stages,反之亦然。
_vertex | _fragment | _hull | _domain | _geometry | _raytracing | 顶点着色阶段 | 片元着色阶段 | ~不知道怎么翻译~ | ~不知道怎么翻译~ | 几何阶段 | 光追阶段 | #pragma multi_compile_local_vertex _ HIT最后在定义宏时有以下限制:
- 每组宏的组内和组间互斥
- dynamic_branch与 shader_feature / multi_compile 同时使用,Unity会默认使用dynamic_branch
- 2020及以下版本,最多可以声明384(2021以上版本几乎没有限制)个全局shader keywords,Unity内置Shader占用60个左右。
- 每个Shader局部宏最多为64个
3.4 Shader运行时
通常Unity加载Shader执行以下步骤:
- 当加载scene或者其它运行时资源, 会将与之相关的Shader变体加载到CPU。CPU解压Shader变体存储在一个独立的区域, 可以通过Other Settings > Shader Variant Loading配置。
- 当GPU第一次需要使用Shader渲染对象时,通过图形API将Shader与其它渲染数据传到图形驱动。
- 图形驱动创建GPU特定的Shader变体,并上传到GPU。
当不再有如何对象引用Shader时,Shader会从CPU和GPU内存中移除。选择变体时,会以相似度进行匹配。若希望严格执行变体的严格匹配,可以通过PlayerSettings.strictShaderVariantMatching设置。
上传GPU的过程会造成程序暂停,为了避免这一问题通常会通过Prewarming,提前将Shade变体加载到GPU:
- 单个Shader预加载:ShaderWarmup.WarmupShader
- 变体收集器预加载:ShaderVariantCollection.WarmUp
- 所有变体预加载:Shader.WarmupAllShaders
对于DirectX 12、Metal和Vulkan的Prewarming,需要精准的GPU Shader变体数据才能完成:
- 使用Experimental.Rendering.ShaderWarmup 需要提供顶点布局以及渲染状态。
- 使用ShaderVariantCollection.Warmup或者Shader.WarmupAllShaders 会创建不精准的Prewarm GPU表现,因为无法提每个Shader的详细关联数据。
参考
- Shaders core concepts
- unwind:跨平台引擎Shader编译流程分析
- [Unity 活动]-Unity 技术开放日 上海站录播_哔哩哔哩_bilibili
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|