Unity 渲染原理(四) Unity Shader基础概念

前面介绍了渲染流水线的基本内容,接下来就其中的我们可以编程的部分进行介绍,开始之前我们需要先介绍一些基础概念,对其中的一些属性进行简单说明。

Shader 简介

Shader类型

Unity中的Shader被分为三种大的类型,分别用于不同的目的:

  • Shader:是渲染流水线的一部分,也是最常使用的一种Shader,这种Shader用于计算屏幕上的每一点的像素值。这种着色器又分为多种类型
    • 顶点着色器
    • 片元着色器
    • 表面着色器(Unity特有,会被编译出顶点着色器和片元着色器)
  • 计算Shader(Compute Shader):在GPU中进行计算,不过该过程并不在图形流水线中。
  • 光线追踪Shader(Ray Tracing Shader):执行与光线追踪有关的计算。

术语表

  • Shader/Shader Program: 一段运行在GPU上的程序,除非特别说明,指的是普通的作为渲染流水线一部分的Shader。
  • Shader Object:Shader Class的一个实例,内部封装了Shader Program以及其他信息。
  • Shader Lab:Unity封装的专用的Shader语言
  • Shader Graph:可视化创建Shader的工具
  • Shader asset:Unity项目中以“.shader”为扩展名的文件,它定义了一个Shader Object
  • Shader Graph asset:Unity项目中的一个文件,定义了一个Shader Object

Shader Class

在Unity中,当我们使用作为流水线一部分的Shader时,我们使用的是Shader Class的一个实例,即Shader Object
Shader Object是Unity专用的一种Shader Program的工作方式,它封装了Shader Program以及其他相关信息,它能够在同一个文件中定义多个Shader Program,并且告知Unity如何使用它们。

Shader Object基础

一个Shader Object包含了Shader Program,修改GPU设置的指令(即render state),以及Unity如何使用它们的信息。

有两种方式可以创建新的Shader Object:

  • 通过写代码的方式创建一个Shader asset(以“.shader”为扩展名的文本文件)
  • 使用Shader Graph创建一个Shader Graph asset
    无论哪种方式,结果都是相同的。

Shader Object 内部结构

一个Shader Object有着一个嵌套的结构,把Shader Program组织成Shader Variants,把内部信息组织成叫做SubShader和Pass的结构。

一个Shader Object包含:

  • 它自身的信息,如它的名字
  • 可选的fallback Shader Object,用于当自身不可用的时候供Unity调用
  • 一个或者多个SubShader

我们也可以定义一些附加的信息,例如共享的Shader code等

SubShader

SubShader允许我们把Shader Object拆分成多个部分,以适应不同的硬件,流水线和运行时的设置

一个SubShader包含:

  • 该SubShader适用的硬件,流水线以及运行时的设置
  • SubShader Tag,一组提供SubShader相关信息的键值对
  • 一个或者多个Pass

我们也可以定义一些附加信息,如适用于所有的所属Pass的render state

Pass

一个Pass包含:

  • Pass Tag,提供Pass相关信息的键值对
  • 运行Pass所属Shader program之前更新render state的指令
  • Shader Program,之后会被组织成Shader variants
    我们还可以定义一些额外信息,如Pass的name

Shader Variants

Pass中的Shader Program会被组织称Shader Variant,着色器变体共享公共代码,但在启用或禁用给定关键字时具有不同的功能。

Pass中的着色器变体的数量取决于在着色器代码中定义的关键字和目标平台。每个Pass包含至少一个变体

渲染过程中关于Shader的调用流程

Unity在使用一个Shader Object之前,会创建一个该Shader Object中SubShader的列表,包括自己定义的和fallback中的

当Unity首次渲染几何体或者Shader的LOD值发生变化或者渲染流水线的设置发生变化时,需要决定采取该Shader Object中的哪一个SubShader:

  • Unity会首先遍历列表中的所有SubShader,找出适合当前设备,小于或者等于当前LOD值的,并且符合流水线设置的SubShader
  • 如果有多个SubShader符合上面的要求,那么就选择符合条件的第一个
  • 如果没有符合条件的SubShader,那么
    • 如果是符合设备要求但是不符合LOD或者流水线设置的要求,那么就在这种SubShader中选择一个
    • 如果是任何要求都不符合,那么就要报错了
      Unity会识别使用相同Shader变体的几何体,然后打包成batch一起进行渲染
  • Unity确定当前SubShader中的哪个Pass它应该呈现,并且在帧中的哪一点。此行为因渲染管道而异
  • 每个Pass它会渲染:
    • 如果当前的render state与Pass中的render state不同,那么改成Pass中的render state
    • GPU使用相关的Shader Variants进行渲染

Shader的编译

每次构建项目时,Unity 编辑器都会编译构建所需的所有着色器:针对每个所需的图形 API 编译每个所需的着色器变体。
当您在 Unity 编辑器中工作时,编辑器不会提前编译所有内容。这是因为为每个图形 API 编译每个变体可能需要很长时间。
相反,Unity 编辑器会这样做:

  • 当导入一个着色器资源时,会执行一些最小的处理(例如表面着色器生成)。
  • 当需要显示着色器变体时,它会检查 Library/ShaderCache 文件夹。
  • 如果找到使用相同源代码的先前编译的着色器变体,则会使用该着色器变体。
  • 如果没有找到匹配项,则编译所需的着色器变体并将结果保存到缓存中。
    • 注意:如果您启用异步着色器编译,它在后台执行此操作并同时显示占位着色器。
      着色器编译使用名为 UnityShaderCompiler 的进程。可启动多个 UnityShaderCompiler 编译器进程(通常在机器中每个 CPU 核心对应一个),这样在播放器构建时就可以并行完成着色器编译。当编辑器不编译着色器时,编译器进程不执行任何操作,也不消耗计算机资源。
      如果有许多经常更改的着色器,着色器缓存文件夹可能会变得非常大。删除此文件夹是安全的;只会导致 Unity 重新编译着色器变体。
      在播放器构建时,所有“尚未编译”的着色器变体都将被编译,因此即使编辑器不会使用这些着色器变体,它们也会存在于游戏数据中。

构建时剥离

在构建游戏时,Unity 可能检测到游戏不使用某些内部着色器变体,并从构建数据中排除(“剥离”)它们。构建时剥离将用于以下各项:

  • 对于使用 #pragma shader_feature 的着色器,Unity 会自动检查是否使用了变体。如果构建中的材质都不使用某个变体,则该变体不会包含在构建中。请参阅内部着色器变体文档。标准着色器会使用此功能。
  • 任何场景未使用的可处理雾效和光照贴图模式的着色器变体不会包含在游戏数据中。如果要覆盖此行为,请参阅 Graphics 窗口。
  • 还可以手动识别变体并使用 OnProcessShader API 告诉 Unity 将这些变体从构建中排除。
    上述的组合通常会大大减小着色器数据大小。例如,完全编译后的标准着色器将占用几百兆字节,但在典型的项目中,通常最终仅占用几兆字节(并且通常会由应用程序打包过程进一步压缩)。

异步Shader编译

一个Shader Object可能会编译出成百上千个Shader变体,如果Unity每次加载一个Shader Object都要把所有的Shader变体都编译完成,那么编辑器可能会卡顿。而且实际上,Unity会按需对变体进行编译,同时使用占位的Shader Object。

异步着色器编译的工作原理是:

  1. 当编辑器第一次遇到未编译的着色器变体时,它会将着色器变体添加到作业线程上的编译队列中。编辑器右下角的进度条会显示编译队列的状态。
  2. 在加载着色器变体时,编辑器使用占位着色器渲染几何体,该着色器显示为纯青色。
  3. 当编辑器完成对着色器变体的编译后,它会使用着色器变体来渲染几何体。

具体还有一些通过设置和代码的方式去开关这种异步编译的方法,就不赘述,可以去看一下异步着色器编译的官方文档

Shader中的分支,变体和关键字(keywords)

有时,我们希望同一个着色器在不同情况下有不同的行为。发生这种情况时,您使用条件来定义不同硬件的不同行为。

本手册的本节包含有关定色器变体和关键字工作以及何时以及如何使用它们的信息。

页面描述
Conditionals in shadersAn introduction to conditionals in shaders, including information on the different types of conditional, and when to use which one.
Shader branchingAn introduction to static and dynamic branching in shaders.
Shader variantsAn introduction to shader variants, and information on how to understand and control how many shader variants Unity compiles.
Shader keywordsAn introduction to shader keywords, and information on how to use them.
Using shader keywords with C# scriptsWorking with shader keywords in C# scripts.
Using shader keywords with the material InspectorWorking with shader keywords in the Unity Editor, using the material Inspector.
着色器变体集合An introduction to shader variant collections, and information on how to use them.

Conditionals in shaders

有的时候,我们想要让同一个Shader在不同情况下有不同的表现,例如,为不同的material配置不同的设置,为不同的硬件定义不同的功能,或者在运行时动态修改Shader的功能。或者在某种情况下避免运行复杂的计算,比如纹理的读取。

我们有三种不同的方式来在不同的情况下让GPU有不同的行为:

  • 静态分支:在shader的编译阶段根据条件不同生成不同的shader
  • 动态分支:GPU在运行时根据条件选择不同的分支
  • Shader变体:Unity使用静态分支来讲Shader的源代码编译成多个shader代码,unity在运行时根据不同的条件选择不同的shader代码

这三种其实也可以分为两种,静态条件和动态条件,因为Shader变体使用的其实也是静态条件,只不过静态分支是在编译阶段根据静态条件把用不到的代码剔除了,而变体则是在编译阶段把每个不同的条件都编译出了一个变体

如何在这三种方式中进行选择,可以看一下官方文档

简单来说,如果事先知道了需要满足的条件,采用静态分支,如果不知道,那么就是动态分支或者shaer变体选一个,动态分支是GPU运行时进行选择,所以会影响GPU性能,Shader变体则是会增加包体积。

Shader Variants(Shader变体)

着色器变体,有时也称为着色器排列,是将条件行为引入着色器代码的一种方法。Unity将着色器源文件编译成着色器程序。每个编译的着色器程序都有一个或多个变体:不同的着色器程序版本适用于不同的条件。在运行时,Unity使用匹配当前需求的变体。你可以使用shader keywords来配置变量。

带有大量变体的着色器被称为mega着色器或uber着色器。Unity的标准着色器就是这样一个着色器的例子。

变体生成的数量

在构建时,Unity为当前构建目标的每个图形API编译一组着色器变体。每个图形API和构建目标组合的变体数量取决于你的着色器源文件,以及你对着色器关键字的使用。

  1. Unity为当前构建目标列表中的每个图形API编译一组着色器变体。每个构建目标和图形API的组合的着色器都不同;例如,Unity在iOS和macOS上为Metal编译不同的着色器。一些着色程序或关键字可能只针对给定的图形API或给定的构建目标,因此图形API和构建目标的每个组合的变体总数可能不同;然而,编译这些变体的过程是相同的。

  2. Unity必须决定为当前的构建目标和图形API组合编译多少Shader Program。对于你的构建中包含的每个着色器源文件,Unity决定了它定义了多少个独特的Shader Program:

  • 一个计算着色器资产定义了一个单独的Shader Program。
  • 在一个手工编码的着色器中,Shader Program的数量取决于你的代码。总共包括:
    • 源文件本身中所有通道中的所有着色器阶段。例如,每个顶点阶段定义一个Shader Program;
    • 每个片段阶段定义一个Shader Program;这包括所有的回退着色器,以及使用UsePass命令包括的所有通道
  • 在Shader Graph Shader中,Shader程序的数量取决于Unity从你的图形中生成的代码。要查看Unity生成的着色器代码,点击shader Graph资产并选择see generated code。然后,你可以确定着色器程序的总数,就像手动编码着色器一样。
  1. keywords影响变体数量
    当Unity决定了它必须为当前的构建目标和图形API编译多少个着色器程序时,它就决定了它必须为每个着色器程序编译多少个着色器变体。

对于每个着色器程序,Unity决定产生不同变体的着色器关键字的组合。这包括:

For example, this set contains three shader variant keywords:

COLOR_RED
COLOR_GREEN
COLOR_BLUE
This set contains four shader variant keywords:

QUALITY_LOW
QUALITY_MEDIUM
QUALITY_HIGH
QUALITY_ULTRA
A shader program affected by those shader variant keywords will result in the following twelve variants:

COLOR_RED and QUALITY_LOW
COLOR_RED and QUALITY_MEDIUM
COLOR_RED and QUALITY_HIGH
COLOR_RED and QUALITY_ULTRA
COLOR_GREEN and QUALITY_LOW
COLOR_GREEN and QUALITY_MEDIUM
COLOR_GREEN and QUALITY_HIGH
COLOR_GREEN and QUALITY_ULTRA
COLOR_BLUE and QUALITY_LOW
COLOR_BLUE and QUALITY_MEDIUM
COLOR_BLUE and QUALITY_HIGH
COLOR_BLUE and QUALITY_ULTRA

当你添加更多的着色器变量关键字集时,Unity编译的变量数量可以快速增长。这种快速增长的术语是组合爆炸。例如,考虑一个相当典型的用例,其中一个着色器有许多着色器变体关键字集,每个关键字包含两个关键字(<特性名> ON和<特性名> OFF)。如果着色器有两个这样的关键字集,这将导致四个变量。如果着色器有10组这样的关键字,这将导致1024个变体

着色器变种的重复数据消除

编译后,Unity自动识别相同的变量在相同的Pass中,并确保这些相同的变量指向相同的字节码。这就是重复数据删除。重复数据删除可防止相同Pass中的相同变量增加文件大小;然而,相同的变体仍然会导致编译过程中的浪费工作,并增加内存使用和着色器加载时间在运行时。记住这一点,最好去掉不需要的变量。

如何从Shader变体角度优化打包体积

有一篇文章写的不错,记录一下:https://blog.unity.com/technology/stripping-scriptable-shader-variants

Shader keywords

Shader 关键字可以让我们在shader code中使用条件行为代码,我们可以创建一些公共的code,但是这些code的行为和功能会根据关键字的不同而不同。

我们可以在集合中定义关键字,一个集合包含包含互斥的关键字,例如:

  • COLOR_RED
  • COLOR_GREEN
  • COLOR_BLUE

我们定义shader关键字的方式会影响一些事情:

  • type:会影响unity为关键字创建shader变体的方式
  • scope:会影响关键字是local还是global的,这个决定了我们是否可以在运行时修改这个关键字
  • stage:影响shader关键字影响的阶段(顶点着色阶段,片元着色阶段等)

type:multi compile 或 shader feature

type分为两种类型,一种是multi compile,一种是shader feature,unity使用它们来创建一个关键字集合来和shader变体配合使用,在unity内部使用这些关键字来创建 #define preprocessor

  • multi compile:定义了一个关键字的集合来配合shader变体使用,unity为集合中的每个关键字编译出变体
  • shader feature:定义了一个关键字的集合来配合shader变体使用,并且同时指示编译器在任何关键字都没有enable的时候去编译变体(unity会在打包时检测每个关键字的状态,并且只为被使用的关键字编译变体。一个关键字是否被使用取决于使用它的material中有没有把这个关键字enable)

选择“multi compile”还是“shader feature”取决于使用关键字的方式。如果使用关键字在项目中配置material,并且不要在运行时将其值从C#脚本更改,则应使用“shader feature”来减少项目中的着色器关键字和变体的数量。如果使用C#脚本在运行时启用和禁用关键字,则应使用“ Multi Compile”来防止变体被剥离。

注意:如果你把一个shader放进了“Always Included Shader”,那么就算选择“shader feature”也会为所有的关键字编译变体。

scope:local 或 global

当您声明一组关键字时,您会选择集合中的关键字是否具有本地或全局范围。这决定了您是否可以使用全局着色器关键字在运行时覆盖此关键字的状态。

默认情况下,您可以使用全局范围声明关键字。这意味着您可以使用全局着色器关键字在运行时覆盖此关键字的状态。如果您用本地范围声明关键字,这意味着您不能使用全局着色器关键字在运行时覆盖此关键字的状态

如果着色器源文件及其依赖项中存在相同名称的关键字,源文件中关键字的范围覆盖了依赖项中的范围

stage

默认情况下,Unity为着色器的每个阶段生成关键字变体。例如,如果您的着色器包含顶点阶段和片段阶段,则Unity为顶点和片段着色程序程序都为每个关键字组合生成变体。如果仅在其中一个阶段中使用一组关键字,则会导致另一个阶段相同的变体。
Unity会自动识别和重复变性,以免增加构建大小,但仍会导致浪费的编译时间,增加着色器加载时间以及增加运行时内存的使用情况。

为了避免此问题,当您在手工编码的着色器中声明一组关键字时,您可以指示Unity仅在给定的着色器阶段进行编译。然后,您负责确保仅在指定的着色器阶段中使用关键字。

图形API不能完全支持特定阶段的关键字。在OpenGL和Vulkan中,在编译时,Unity自动将所有特定阶段的关键字指令转换为常规关键字指令。在Metal中,任何针对顶点阶段的关键字也会影响镶嵌阶段,反之亦然。

shader keyword的修改

我们可以通过代码或者material inspector的方式去设置keyword的enable还是disable,具体可以看官方文档:Using shader keywords with C# scripts, Using shader keywords with the Material Inspector