Unity Shader入门精要 第十七章

  1. 1. Unity中的表面着色器探秘
  2. 2. 17.0
  3. 3. 17.1
  4. 4. 17.2 编译指令
    1. 4.0.1. 17.2.1 表面函数
    2. 4.0.2. 17.2.2 光照函数
    3. 4.0.3. 17.2.3 其他可选参数
  • 5. 17.3 两个结构体
    1. 5.0.1. 17.3.1 数据来源:Input结构体
    2. 5.0.2. 17.3.2 表面属性:SurfaceOutput结构体
  • 6. 17.4 Unity背后做了什么
  • 7. 17.5 表面着色器实例分析
  • 8. 17.6 表面着色器的缺点

  • Unity中的表面着色器探秘


    17.0

    • 在一般的渲染流程中,一般会分为顶点着色器和片元着色器两个阶段。但Unity的渲染工程师Aras则不这么认为,觉得这是一种不易理解的抽象
    • 他说,这种在顶点/几何/片元着色器上的操作是对硬件友好的—种方式,但不符合我们人类的思考方式
    • 相反,他认为,应该划分成表面着色器、光照模型和光照着色器这样的层面
    • 其中,表面着色器定义了模型表面的反射率、法线和高光等,光照模型则选择是使用兰伯特还是Blinn-Phong等模型。而光照着色器负责计算光照衰减、阴影等
    • 这样,绝大部分时间我们只需要和表面着色器打交道,例如,混合纹理和颜色等。光照模型可以是提前定义好的,我们只需要选择哪种预定义的光照模型即可
    • 而光照着色器一旦由系统实现后,更不会被轻易改动,从而大大减轻了Shader编写者的工作量。有了这样的想法,Aras在随后的文章中开始尝试把表面着色器整合到 Unity中
    • 最终,在2010年的Unity 3中,表面着色器(Surface Shader)被加入到Unity的大家族中了
    • 虽然Unity换了一个新的”马甲”,但表面着色器实际上就是在顶点/片元着色器之上又添加了一层抽象
    • 按Aras的话来解释就是,顶点/几何/片元着色器是硬件能”理解”的渲染方式,而开发者应该使用一种更容易理解的方式
    • 很多时候我们使用表面着色器,只需要告诉Shader,应该用纹理去填充颜色,以及使用法线纹理去填充表面法线,以及使用兰伯特光照模型,我们不需要考虑是使用前向渲染路径还是延迟渲染路径。场景中有多少光源、它们的类型是什么、怎样处理这些光源、每个Pass需要处理多少个光源等问题

    17.1

    • 可以看到,相比于顶点/片元着色器来实现同样一个功能,表面着色器代码减少了,同时在场景中添加的点光源和聚光灯也可以很好的去实现光照效果
    • 同时可以看到,实现光照模型的过程非常简单,甚至不需要和任何光照变量打交道,Unity就帮我们处理好了每个光源的光照结果;和顶点/片元着色器需要包含到一个特定的Pass中不同,表面着色器的C代码是直接也必须写在SubShader中,Unity会在背后生成多个Pass,当然我们可以在SubShader一开始处使用Text来设置该表面着色器所使用的标签
    • 一个表面着色器最重要的部分是两个结构体,以及编译指令

    17.2 编译指令

    编译指令

    • 编译指令会指明表面着色器所使用的表面函数和光照函数
    • 这里的surface指明定义表面着色器的,surf是表面函数,Lambert是光照模型(光照函数)

    17.2.1 表面函数

    • 表面着色器的优点在于抽象出了”表面”这一概念。与之前遇到的顶点/片元抽象层不同,一个对象的表面属性定义了它的反射率、光滑度、透明度等值
    • 而编译指令中的surfaceFunction就用于定义这些表面属性
    • surfaceFunction通常就是名为surf的函数(函数名可以是任意的),它的函数格式是固定的
    • void surf (input IN, inout SurfaceOutput o)
    • void surf (input IN, inout SurfaceOutputStandard o)
    • void surf (input IN, inout SurfaceOutputStandardSpecular o)
    • 后两个是Unity5中,由于引入了基于物理的渲染,而新添加的两个结构体
    • 它们需要配合不同的光照模型使用,会在后面介绍

    17.2.2 光照函数

    • 光照函数会使用表面函数中设置的各种表面属性,来应用某些光照模型模拟物品表面的光照效果
    • Unity内置了基于物理的光照模型函数,Standard和StandardSpecular(在UnityPBSLighting.cginc文件中被定义),以及简单的非基于物理的光照模型函数Lambert和Blinn-Phong(在Lighting.cginc文件中被定义)
    • 我们也可以定义自己的光照函数,例如:
      • half4 Lighting <Name>(SurfaceOutput s, half3 lightDir, half atten); // 用于不依赖视角的光照模型,例如漫反射
      • half4 Lighting <Name>(SurfaceOutput s, half3 lightDir, half3 vieDir, half atten); // 用于依赖视角的光照模型,例如高光反射

    17.2.3 其他可选参数

    • 在编译指令的最后,我们还可以设置一些可选参数(optionalparams)。这些可选参数包含了很多非常有用的指令类型,例如,开启/设置透明度混合/透明度测试,指明自定义的顶点和颜色修改函数,控制生成的代码等。下面将选取一些比较重要和常用的参数进行更深入地说明

    • 自定义的修改函数:除了表面函数和光照模型外,表面着色器还可以支持其他两种自定义的函数,顶点修改函数和最后的颜色修改函数

      • 其中顶点修改函数可以为我们制定一些顶点属性,比如把顶点颜色传递给表面函数,或是修改顶点位置,实现某些顶点动画等等
      • 颜色修改函数则可以在颜色绘制到屏幕前,最后一次修改颜色值,比如实现自定义的雾效等
    • 阴影:我们可以通过一些指令来控制和阴影相关的代码

    • 透明度混合和透明度测试:我们可以通过Alpha和AlphaTest指令来控制透明度混合和透明度测试

    • 光照:一些指令可以控制光照对物体的影响

    • 控制代码的生成:一些指令还可以控制由表面着色器自动生成的代码,默认情况下Unity会为一个表面着色器生成相应的前向渲染路径、延迟渲染路径使用的Pass

    17.3 两个结构体

    • 上节讲过,表面着色器支持最多自定义4种关键的函数:表面函数,用于设置各种表面性质,如反射率、法线等等;光照函数,是定义表面使用的光照模型;顶点修改函数,可以修改或传递顶点属性;颜色修改函数,对最后的颜色进行修改
    • 这些函数的信息传递是怎么实现的呢,比如想把顶点颜色传递给表面函数,从而添加到表面反射率的计算中,要怎么做呢,这就是两个结构体的工作
    • 一个表面着色器需要使用两个结构体,分别是表面函数的输入结构体Input及存储表面属性的结构体SurfaceOutput,Unity5中又引入了两种同种的结构体,SurfaceOutputStandard和SurfaceOutputStandardSpecular

    17.3.1 数据来源:Input结构体

    • Input结构体包含了许多表面属性的数据来源,因此,它会作为表面函数的输入结构体(如果定义了顶点修改函数,它还会是顶点修改函数的输出结构体)
    • Input支持很多内置的变量名,通过这些变量名可以告诉Unity需要使用的数据信息,比如uv_MainTex和uv_BumpMap,这些采样坐标是必须以”uv”为前缀的,实际上也可以使用”uv2”,表明是使用次纹理坐标结构,后面紧跟纹理名称。
      • 以主纹理_MainTex为例,如果需要使用它的采样坐标,就需要在Input结构体中声明float2 uv_MainTex
    • 其他变量:
      • float3 viewDir:包含了视角方向,可用于计算边缘光照等
      • 使用COLOR语义定义的float4变量:包含了插值后的逐顶点颜色
      • float4 screenPos:包含了屏幕空间的坐标,可以用于反射或屏幕特效
      • float3 worldPos:包含了世界空间下的位置
      • float3 worldRefl:包含了世界空间下的反射方向,前提是没有修改表面法线o.Normal
      • float3 worldRefl; INTERNAL_DATA:如果修改了表面法线o.Normal,需要使用该变量告诉Unity要基于修改后的法线计算世界空间下的反射方向。在表面函数中,需要使用WorldReflectionVector(IN, o.Normal)来得到世界空间下的反射方向
      • float3 worldNormal:包含了世界空间的法线方向,前提是没有修改表面法线o.Normal
      • float3 worldNormal; INTERNAL_DATA:如果修改了表面法线o.Normal,需要使用该变量告诉Unity要基于修改后的法线计算世界空间下的法线方向。在表面函数中,需要使用WorldNormalVector(IN, o.Normal)来得到世界空间下的法线方向
    • 我们不需要去计算这些变量,而只需在Input结构体上按上述名称严格声明这些变量就可以了,Unity会在背后为我们准备好这些数据,我们只需在表面函数中直接使用就可以了
    • 一个例外的情况是,我们定义的顶点修改函数,必须要向表面函数中传递一些自定义的数据。比如自定义雾效,我们可能需要在顶点修改函数中根据顶点在视角空间下的位置信息去计算雾效混合系数,像这样我们就可以在Input结构体中去定义自己的变量,并把计算结果存储在该变量后进行输出

    17.3.2 表面属性:SurfaceOutput结构体

    • 有了Input结构体来提供所需要的数据后,我们就可以据此计算各种表面属性

    • 因此,另一个结构体就是用于存储这些表面属性的结构体,即SurfaceOutput、SurfaceOutputStandard和SurfaceOutputStandardSpecular,它会作为表面函数的输出,随后会作为光照函数的输入来进行各种光照计算

    • 相比与Input结构体的自由性,这个结构体里面的变量是提前就声明好的,不可以增加也不会减少(如果没有对某些变量赋值,就会使用默认值)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      struct SurfaceOutput
      {
      fixed3 Albedo;
      fixed3 Normal;
      fixed3 Emission;
      half Specular;
      fixed Gloss;
      fixed Alpha;
      };

      struct SurfaceOutputStandard
      {
      fixed3 Albedo; // base (diffuse or specular) color
      fixed3 Normal; // tangent space normal, if written
      half3 Emission;
      half Metallc; // 0 = non-metal, 1 = metal
      half Smoothness; // 0 = rough, 1 = smooth
      half Occlusion; // occlusion (default 1)
      fixed Alpha; // alpha for transparencies
      };

      struct SurfaceOutputStandardSpecular
      {
      fixed3 Albedo; // diffuse color
      fixed3 Specular; // specular color
      fixed3 Normal; // tangent space normal, if written
      half3 Emission;
      half Smoothness; // 0 = rough, 1 = smooth
      half Occlusion; // occlusion (default 1)
      fixed Alpha; // alpha for transparencies
      };
    • 在一个表面着色器中,我们只需选择上述三者中的其一就可以了,这取决于所使用的光照模型

    • Unity内置的光照模型有两种,一种是Unity5之前的简单的非基于物理的光照模型,包含了Lambert和Blinn-Phong;另一种是Unity5添加的基于物理的光照模型,包括Standard和StandardSpecular,这种模型会更加符合物理的规律,但是计算也会复杂很多

    • 一般使用非基于物理的光照模型就使用第一种结构体,使用基于物理的光照模型就使用下面两种结构体

    • 其中SurfaceOutputStandard结构体用于默认的金属工作流程,而SurfaceOutputStandardSpecular结构体用于高光工作流程


    • 本节中先介绍SurfaceOutput结构体中的变量和含义
    • 在表面函数中,我们需要根据Input的结构体去传递各个变量,计算表面的属性
    • 这里对光源的反射率进行计算,它通常是由采样纹理和颜色属性来得到的
    • 然后是Normal表面法线方向
    • 然后是Emission自发光,Unity通常会在片元着色器最后输出前,通过使用简单的颜色叠加来加上Emission的值
    • 之后是Specular,是高光反射中的指数部分的系数,它会影响高光反射的计算
    • 接下来是Gloss,它是高光反射中的强度系数
    • 最后是Alpha,透明通道,如果开启透明度的话,会使用该值进行颜色混合

    17.4 Unity背后做了什么

    • 尽管表面着色器极大的减少了我们的工作量,但它带来的另一个问题是,我们经常不知道为什么会得到这样的渲染结果,如果我们不管这些的话,我们可以很轻松地用它来实现一些不错的渲染效果。但是我们往往也会有这样的疑问,为什么场景里没有灯光,但物体不是全黑的;又或者把光源的颜色调成黑色,物体还是会有一些渲染颜色。这些问题都源于表面着色器对我们隐藏了一些实现的细节。如果想要更加得心应手地使用表面着色器,我们需要学习它的工作流水线,并了解Unity是如何为一个表面着色器生成对应的顶点/片元着色器的
    • 表面着色器的本质就是包含了很多Pass的顶点/片元着色器
    • 这些Pass有些是为了针对不同的渲染路径,例如,默认情况下Unity会为前向渲染路径生成LightMode为ForwardBase和ForwardAdd的Pass,为Unity5之前的延迟渲染路径生成LightMode为PrePassBase和PrePassFinal的Pass,为Unity5之后的延迟渲染路径生成LightMode为Deferred的Pass
    • 可以在Unity中点击Show generated code按钮来查看背后生成的代码,通过查看这些代码,我们就可以了解Unity是如何根据表面着色器来生成各个Pass
    • 首先会直接将表面着色器之间的cg代码部分复制过来,这些代码包含了Input结构体和表面函数、光照函数等变量和函数的定义

    • 对比前面的表面着色器代码,前面部分是完全一样的,在cg代码之间就不一样了,我们在表面着色器定义的这些函数和变量会在之后的处理过程中被当成正常的结构体和函数来进行调用
    • 流程为:顶点数据——>输入给顶点着色器——>输入给片元着色器。在顶点着色器中又会经过两块,第一块是顶点修改函数,再经过根据Input的需要去计算的变量,并存储在相应的结构体中,之后输入到片元着色器,在片元着色器中,首先会填充Input结构体,然后进入表面函数,再进入光照函数,如果有其他对颜色的修改,比如添加逐顶点光照的话,会继续往后加入可选的函数,最后输出最终颜色

    • 在代码中,首先会生成顶点着色器的输出,也就是v2f_surf结构体,用于在顶点着色器和片元着色器之间进行数据传递,Unity会分析我们在自定义函数中所使用的变量,比如纹理坐标、视角方向、反射方向等等,会根据我们预定义的不同,分别调用不同的surface结构体。有时我们在Input中定义了某些变量,但Unity在分析后续代码时,如果发现我们并没有使用这些变量的话,那么这些变量是不会在v2f的surface中生成的,也就是说Unity做了一些优化,v2f中还包含了一些其他需要的变量,就比如阴影纹理坐标、光照纹理坐标、逐顶点光照等等

    • 接着去生成顶点着色器,也就是vert_surf,Unity会首先调用顶点修改函数来修改顶点数据,或填充我们自定义的Input结构体变量,然后Unity会分析顶点着色器中修改的数据,在需要时通过Input结构体将修改的结果存储到v2f_surf相应的变量中。在计算v2f_surf中其他生成的变量值里面,会包括顶点位置、纹理坐标、法线方向、逐顶点光照、光照纹理的采样坐标等等。最后通过v2f_surf传递给接下来的片元着色器

    • 片元着色器也就是frag_surf,它会使用v2f_surf中的对应变量来填充Input结构体,比如纹理坐标、视角方向等等,以及它会去调用我们自定义的表面函数,接下来调用光照函数去得到初始的颜色值。如果使用的是内置Lambert或Blinn-Phong光照函数的话,Unity还会去计算动态全局光照,并添加到光照模型的计算中,之后就进行其他的颜色叠加,比如如果没有使用光照烘焙的话,还会添加逐顶点的光照的影响,最后,如果自定义了最后的颜色修改函数,Unity还会调用它来进行最后的颜色修改

    17.5 表面着色器实例分析

    实现沿法线方向扩张顶点位置



    • 为了分析表面着色器中4个可自定义函数(顶点修改函数、表面函数、光照函数和最后的颜色修改函数)的原理,在本例中对这4个函数全部采用了自定义的实现方式
    • 在顶点修改函数void myvert中,使用顶点法线来对顶点位置做偏移
    • 表面函数void surf中,使用了主纹理去设置表面属性中的反射率,并使用法线纹理去设置表面的法线方向
    • 光照函数half4 LightingCustomLambert就是实现简单的Lambert漫反射光照模型
    • 在最后的颜色修改函数void mycolor中,简单地使用颜色参数对输出的颜色进行调整
    • 除了这4个函数外,还在#pragma surface编译指令一行中指定了一些额外的参数。由于修改了顶点位置,需要对其他物体产生正确的阴影效果,它并不能直接依赖某FallBack中找到的阴影投射的Pass。addshadow的参数告诉Unity要生成一个该表面着色器对应的阴影投射的Pass,默认情况下Unity会为所有支持的渲染路径去生成相应的Pass,我们为了缩小自动生成的代码量,可以使用exclude_pass:deferred和exclude_pass:prepass来告诉Unity不要为延迟渲染路径生成相应的Pass。最后使用nometa的参数去取消对提取原数据的Pass生成
    • Unity会生成3个Pass,即ForwardBase、ForwardAdd及ShadowCaster,分别对应了前向渲染路径中的处理逐像素平行光的Pass、处理其他像素光的Pass、处理阴影投射的Pass,其中有大量的if和def语句,这些可以判断一些渲染条件,比如是否使用了动态光照纹理,是否使用了逐顶点光照,是否使用了屏幕空间的阴影等等,Unity会根据这些条件来进行不同的光照计算,而这正是表面着色器的魅力,把所有烦人的光照计算都交给Unity来做

    17.6 表面着色器的缺点

    • 表面着色器只是Unity在顶点/片元着色器上面提供的一种封装,是一种更高层的抽象
    • 任何在表面着色器中完成的事情,都可以在顶点/片元着色器中重现。不幸的是,这句话反过来并不成立
    • 任何事情都是有代价的,如果我们想要得到便利,就需要牺牲自由度为代价
    • 表面着色器虽然可以快速实现各种光照效果,但我们失去了对各种优化和各种特效实现的控制
    • 因此,使用表面着色器往往会对性能造成一定的影响,而内置的Shader,例如Diffuse、Bumped Specular等都是使用表面着色器编写的
    • 尽管Unity提供了移动平台的相应版本,例如Mobile/Diffuse和Mobile/Bumped Specular等,但这些版本的Shader往往只是去掉了额外的逐像素Pass、不计算全局光照和其他一些光照计算上的优化