参考书籍:冯乐乐 - 《Unity Shader入门精要》

1 - 渲染流水线

在《Render-Time Rendering,Third Edition》一书中,提出将一个渲染流程分为3各阶段:应用阶段(Application Stage)、几何阶段(Geometry Stage)、光栅化阶段(Rasterizer Stage)

1.1 - 应用阶段

应用阶段(在CPU上):

  • 将数据加载到显存

  • 设置渲染状态

    渲染状态:定义了场景中的网格是怎么被渲染的,使用了什么顶点、片元着色器,光源属性和材质等。

  • Draw Call

    Draw Call:CPU告诉GPU如何去进行渲染的指令,即根据渲染状态和所有输入的顶点数据。

1.2 - 几何阶段

几何阶段:顶点数据通过顶点着色器等一系列着色器进行空间变换、顶点着色,最终经过裁剪映射到屏幕空间。

  • 顶点着色器:最主要的工作是坐标变换、逐顶点光照。常用于实现顶点动画。一个最基本的顶点着色器需要完成工作是将顶点坐标转换到齐次剪裁空间中。

  • 剪裁:剔除不在视野范围的物体数据。

  • 屏幕映射:将每个图元的x和y坐标转换到屏幕坐标系下。

    屏幕坐标系在OpenGLDirectX中存在差异,OpenGL中是数学中常用的由左向右,由下向上的坐标系,DirectX则是由左往右,由上往下,符合人类的阅读顺序的坐标系。

    如果发现得到的图像是反的,那么有可能是这个原因造成的。

image-20230630211935996

1.3 - 光栅化阶段

光栅化阶段:计算每个图元覆盖了哪些像素,为这些像素计算它们的颜色。

  • 三角形设置:计算光栅化一个三角网格所需的信息。

  • 三角形遍历:检查每一个像素是否被一个三角网格覆盖,如果是就会生成一个片元。

    片元不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。包括屏幕坐标,深度信息,以及其他从几何阶段输出的顶点信息例如:法线、纹理坐标等。

  • 片元着色器:这个阶段可以完成许多渲染技术,其中最重要的就是纹理采样。

  • 逐片元操作:决定每个片元的可见性,涉及很多测试工作:例如深度测试模板测试。如果一个片元通过了所有的测试,那么就将这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行混合。

    理解模板测试和深度就能够理解之后章节提到的渲染队列,尤其是处理透明效果时出现的问题。如果一个片元通过了模板测试,那么他会进行下一个深度测试。

    • 模板测试:通常用于限制渲染的范围。
    • 深度测试:显示出离摄像机最近的物体,剔除其他被遮挡的部分。
    • 合并:如果一个片元通过了模板测试和深度测试,那么就可以进行合并。对于半透明的物体,需要混合操作让这个物体看起来是透明的。混合类似于Photoshop中的图层混合模式,会根据透明通道的值进行相加、相减、相乘等。
    • 此外,在Unity的渲染流水线中,深度测试是在片元着色器之前的,这种技术也称为Early-Z技术,通过尽可能地早知道哪些片元是会被舍弃的,提高了GPU的性能。

当图元经过了光栅化阶段的计算和测试后,就会显示到我们的屏幕上。我们的屏幕上显示的就是颜色缓冲区中的颜色值,为了避免我们看到那些正在光栅化的图元,GPU会使用双重缓冲(Double Buffering)策略。即对于场景的渲染是在幕后的后置缓冲(Back Buffer)中进行的,当场景被渲染到后置缓冲中,GPU就会交换后置缓冲和前置缓冲(Front Buffer)中的内容,由此来保证我们看到的图像总是连续的。

1.4 - Draw Call

Draw Call:Draw Call是CPU调用图像接口命令,如OpenGL或者DirectX中的相关命令,以指示GPU进行渲染的操作。

  1. CPU和GPU之间如何实现并行工作?

    使用命令缓冲区,其中包含的命令有很多,除了Draw Call以外还有改变渲染状态等。

  2. 为什么Draw Call多了会影响帧率?

    分配内存、创建元数据这些操作多了会造成很多额外的性能开销,如果复制了很多小文件,那么这种性能开销会很大。

  3. 如何减少Draw Call?

    减少Draw Call的方法有很多,比如:批处理

    批处理是将很多的Draw Call合并成一个大的Draw Call,通过合并网格,适用于静态的物体,如将不会移动的地面合并。当然也可以对动态物体进行批处理,但是这些物体是不断移动的,因此每一帧都需要进行合并,这也会造成一定的性能开销。

    为了减少Draw Call,需要注意:

    • 尽量避免使用大量很小的网格,如果不可避免,那么可以考虑合并他们
    • 尽量避免使用过多的材质,在不同的网格之间使用相同的材质。

1.5 - Shader在渲染流水线中扮演的角色

  • Shader是GPU流水线上的一些可高度编程的阶段,由Shader编译出来的代码最终会运行在GPU上。
  • 有一些特定的着色器:顶点着色器、片元着色器
  • 依靠Shader我们可以控制渲染流水线上的细节,如使用顶点着色器来进行顶点变换或者传递数据,使用片元着色器来进行逐像素的渲染

2 - Unity Shader基础

2.1 - Unity Shader 概述

参考资料

Writing Your First Shader In Unity - Rendering In Unity:https://www.youtube.com/watch?v=ZNvEM76Zi-c&list=PLX2vGYjWbI0RS_lkb68ApE2YPcZMC4Ohz&index=2

image-20230628171902384

一个完整的渲染过程需要包括以下资产:3D模型材质以及Shader

  • 其中,3D模型包含顶点顶点颜色UV数据以及法线
  • 在Unity中,要渲染的游戏对象带有添加了材质Mesh Render渲染器,材质声明了使用的Shader、纹理贴图、颜色以及属性信息。
  • Shader使用来自3D模型材质中的信息,使用CG/HLSL代码在屏幕上绘制像素。

image-20230628173214190

  • Shader是一种计算机程序,在渲染过程中用于进行着色:在图像中产生适当的光亮、暗淡和颜色,或者在现代也用于产生特殊的平面效果或进行视频后期处理(如图像效果)。
  • Shader是一个专门在GPU上运行的程序。它最终用于绘制你的3D模型的三角面。

一个Unity Shader的基本结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
Shader "ShaderName"
{
Properties
{
//属性
}
SubShader
{
//显卡A使用的子着色器
}
}

2.2 - SubShader

Unity可以包含多个SubShader语义,但至少要有一个。SubShader定义了一些列的Pass以及状态(**[RenderSetup])、标签([Tag])的设置。如果Pass**的数目过多,会造成性能的下降。

Unity会选择第一个能够在目标平台上运行的SubShader,如果都不支持的话就会使用Fallback语义中指定的Unity Shader。这样做的好处是能够在新旧的显卡上面呈现不同计算复杂度的画面。

以下是SubShader语义块包含的内容:

1
2
3
4
5
6
7
SubShder{
[Tags] //可选
[RenderSetup] //可选
Pass{
}
//Other Pass
}

2.2.1 - 状态设置

  • 状态设置:ShaderLab提供的一系列渲染状态的设置指令,例如是否开启混合/深度测试。
状态名称 设置指令 解释
Cull Cull Back| Front |Off 设置剔除指令,剔除背面/正面/关闭剔除
ZTest ZTest Less Greater | LEqual | GEqual | NotEqual | Always 设置深度测试时使用的函数
ZWrite ZWrite On | Off 开启/关闭深度写入
Blend Blend SrcFactor DstFactor 开启并设置混合模式

当在SubShader中设置了上面的渲染状态之后,会应用到所有的Pass中,如果不想这样,那么可以在Pass中单独设置。

  • ZWrite off:这是深度写入(Z写入)的设置。将其设置为off表示在渲染此Shader时不会写入深度缓冲区。这意味着该对象将不会对后续的渲染产生深度测试的影响,而是始终绘制在已经渲染的像素之上,不考虑深度。

    https://docs.unity3d.com/Manual/SL-ZWrite.html

  • Blend SrcAlpha OneMinusSrcAlpha:这是混合(Blending)的设置。它指定了使用源颜色的alpha通道和目标颜色的反相alpha通道进行混合。具体来说,SrcAlpha表示使用源颜色的alpha通道作为混合因子,OneMinusSrcAlpha表示使用目标颜色的反相alpha通道作为混合因子。这种混合方式常用于实现半透明效果,源颜色会根据其alpha值与背景进行混合,产生透明效果。

    https://docs.unity3d.com/530/Documentation/Manual/SL-Blend.html

2.2.2 - 标签

  • 标签:SubShader的标签(**[Tag])是一个键值对,它用来告诉Unity希望怎样以及何时**渲染这个对象。
标签类型 说明 例子
Queue 控制渲染顺序 Tags { “Queue”=”Transparent” }
RenderType 对着色器进行分类,例如用来区别透明和不透明着色器 Tags { “RenderType”=”Opaque” }
DisableBatching 一些SubShader在只要批处理会出现问题,例如使用了模型空间下的坐标进行顶点动画。这时可以通过此标签来取消该SubShader对于批处理的使用。 Tags { “DisableBatching”=”True” }
ForceNoShadowCasting 控制该物体是否会投射阴影 Tags { “ForceNoShadowCasting”=”True” }
IgnoreProjector 如果该标签为**”True”,那么该物体不会受Projector的影响,通常用于半透明物体**。 Tags { “IgnoreProjector”=”True” }
CanUseSpriteAtlas 当该对象是Sprite时,将该标签设置为**”False”** Tags { “CanUseSpriteAtlas”=”False” }
PreviewType 指明材质面板该如何预览该材质,例如”Plane“、”Skybox“ Tags { “PreviewType”=”Plane” }

2.2.3 - Pass

  • Pass语义块

以下是Pass语义块包含的内容:

1
2
3
4
5
Pass{
[Name]
[Tag]
[RenderSetup]
}

我们可以在Pass中定义该Pass的名称,例如:

1
Name ”MyPassName“

ShaderLab中,我们可以使用UsePass命令来直接使用其他Unity Shader中的Pass

例如:

1
UsePass ”MyShader/MYPASSNAME"

需要注意的是,Unity内部会将所有的Pass的名称转换成大写字母的表示,因此,在使用UsePass命令的时候必须使用大写形式的名字。

SubShader中的状态设置同样使用与Pass,在Pass中我们还可以使用固定管线的着色器。

在Pass中同样可以设置标签,但是不同于SubShader中的标签。

标签类型 说明 例子
LightMode 定义该Pass在Unity渲染流水线中的角色 Tags { “LightMode”=”ForwardBase” }
RequireOptions 当满足某些特定条件时才渲染该Pass Tags { “RequireOptions”=”SoftVegetation” }

除了上面定义的Pass之外,Unity Shader还支持一些特殊的Pass:

  • UsePass:可以使用指令来复用其他Shader中的Pass
  • GrabPass:负责抓取屏幕并将结果存储在一张纹理中,一边用于后续的Pass处理。

2.2.4 - Fallback

在各个SubShader语义块之后的,可以是一个Fallback指令,它用于告诉Unity,如果上面的SubShader都无法在这张显卡上运行,那就是用这个最低级的Shader。

它的语句如下:

1
2
Fakkback “Name”
Fallback off

另外,在渲染阴影纹理时,Unity会在每个Unity Shader中寻找一个阴影投射的Pass,通常情况下,我们不需要专门实现一个Pass,因为Fallback使用的内置Shader会包含这样一个通用的Pass,因此,对每个Unity Shder正确设置一个Fallback是非常重要的。

2.2.5 - 其他语义

除了上面的语义之外,Unity还包含以下不常用的语义:

  • CustomEditor:自定义材质面板的编辑画面,用来扩展编辑界面
  • Category:对Unity Shader中的命令进行分组

2.3 - Unity中的几种着色器类型

以下是Unity中内置的几种着色器:

  • 表面着色器(Surface Shaders)采用了代码生成的方法,相比使用低级顶点/像素着色器程序,更容易编写光照着色器。使用基于物理的渲染方法。

    表面着色器是顶点/片元着色器的更高一层的抽象,Unity为我们处理了很多光照细节,我们不需要在计算这些。

    以下是一个表面着色器的示例:

    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
    32
    33
    34
    35
    36
    37
    38
    Shader "Custom/TestSurfaceShader"
    {
    Properties
    {
    }
    SubShader
    {
    Tags { "RenderType"="Opaque" }
    LOD 200

    CGPROGRAM
    #pragma surface surf Standard fullforwardshadows
    #pragma target 3.0

    struct Input
    {
    float2 uv_MainTex;
    };

    half _Glossiness;
    half _Metallic;
    fixed4 _Color;

    UNITY_INSTANCING_BUFFER_START(Props)
    UNITY_INSTANCING_BUFFER_END(Props)

    void surf (Input IN, inout SurfaceOutputStandard o)
    {
    fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
    }
    ENDCG
    }
    FallBack "Diffuse"
    }

    表面着色器不关心Pass,只要使用纹理去填充颜色、法线纹理去填充法线,使用Lambert光照模型,其他的都交给Unity,

    Unity还是会把它转换成一个包含多个Pass的顶点/片元着色器。

  • 无光照着色器(Unlit Shaders)不与Unity的光源进行交互,适用于特殊效果,灵活性较高。

  • 图像效果着色器(Image Effect Shaders)通常是一种后期处理效果,它读取源图像,对其进行一些计算,并将结果渲染到提供的目标图像中(例如使用Graphics.Blit)。

  • 计算着色器(Compute Shaders)是在显卡上运行的程序,独立于正常的渲染流程。它们可用于大规模并行的GPU算法,或者加速游戏渲染的某些部分。旨在利用GPU的并行性来进行一些与常规渲染流水线无关的计算。

3 - Shader 相关数学基础

3.1 - 笛卡尔坐标系

三维中的笛卡尔坐标系并不都是等价的,如果他们具有相同的旋向性,那么我们可以通过旋转的方式让他们的坐标轴重合。如果他们具有不同的旋向性,例如下图的左手坐标系和右手坐标系,那么就无法通过旋转的方式让他们的坐标系重合。

image-20230703103234130

食指朝上,向上的方向就是y轴的正方向,中指朝前,向前的方向就是z轴的正方向,剩下的拇指的方向就是x轴的正方向。

模型空间世界空间中,Unity使用的是左手坐标系。蓝色代表z轴,绿色代表y轴,红色代表x轴。

但是对于观察空间来说,Unity使用的是右手坐标系

观察空间是以摄像机为原点的坐标系,摄像机的前向是z轴的负方向,z轴越小,物体深度越大,离摄像机月远。

image-20230703105207299

4 - Unity Shader

4.1- 基本的Shader结构

4.1.1 - 基本Shader代码包含的内容

基本的一个Unlit Shader代码包含以下内容:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Shader "Unlit/Test"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
float4 _MainTex_ST;

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}
1
2
#pragma vertex vert
#pragma fragment frag

他们将告诉Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

vert函数的输入v包含了这个顶点的位置和UV,他们是由POSITIONTEXCOORD0指定的,他们都是CG/HLSL中的语义

POSITION告诉Unity,把模型的顶点坐标填充到appdata中的参数vertex中,SV_POSITION告诉Unity,v2f中的vertex就是裁剪空间中的顶点坐标。

v2f的含义是把顶点着色器的数据传输到片元着色器中:Vertex to Fragment。

1
2
3
4
5
6
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
  • 冒号(:)后面的**”SV_Target”表示此函数的返回值将被写入到指定的渲染目标(Render Target)中。在这种情况下,它指示将片段着色器(frag)的输出颜色写入到默认的渲染目标(通常是屏幕上的像素缓冲区)中。这里返回了一个表示纹理颜色fixed4**类型的变量
  • 在Unity的着色器中,片段着色器负责计算每个像素的颜色输出。通过使用**”SV_Target”**语义,我们可以指定将这个输出颜色写入到哪个渲染目标中,从而控制着色器的渲染结果在场景中的影响位置。
  • 需要注意的是,这段代码中使用了默认的”SV_Target”语义,这意味着输出颜色将直接写入默认的渲染目标。在特定的渲染管线或自定义渲染流程中,可以使用其他语义来指定不同的渲染目标。

4.1.2 - 获取模型数据

我们可以使用纹理坐标来访问纹理,法线可以用于计算光照。

1
2
3
4
5
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

通过声明一个新的结构体appdata,它包含了顶点着色器需要的模型数据。

对应顶点着色器的输出,Unity支持的语义有:POSITION, TANGENT , NORMALTEXCOORD0,TEXCOORD1, TEXCOORD2TEXCOORD3,COLOR

填充到POSITIONNORMALTEXCOORD0中的数据是由材质Mesh Render组件提供的。

在调用Draw Call的时候,Mesh Render会将他负责渲染的模型数据发送给Unity Shader。

一个模型通常包含一组三角面片,每个三角面由3个顶点构成,而每个顶点又包含了一些数据,如:顶点位置、法线、切线、纹理位置、顶点颜色等。

4.1.3 - 顶点着色器与片元着色器之间的通信

1
2
3
4
5
struct v2f
{
float4 pos : SV_POSITION;
fixed3 color : COLOR0;
};

v2f用于在顶点着色器与片元着色器之间传递信息。

我们使用了SV_POSITIONCOLOR0语义,顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITIONCOLOR0语义中的数据可以由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色。类似的语义还有COLOR0

顶点着色器是逐顶点调用的,而片元着色器是逐片元调用的,片元着色器的输入实际上是把顶点着色器的输入进行插值后得到的结果。

4.1.4 - 属性

Shader Lab中的属性类型和CG之间变量类型之间的匹配关系:

Shader Lab属性类型 CG变量类型
Color,Vector float4,half4,fixed4
Range,Float float,half,fixed
2D sample2D
Cube sampleCube
3D sample3D

4.2 - Unity内置文件和变量

为了方便开发,Unity提供了很多内置文件,包含许多提前定义的函数、变量、宏等。

例如:

1
#include "UnityCG.cginc"

我们可以前往官网下载,选择下载Built-in Shader。

下载地址:https://unity.com/releases/editor/archive

以下是下载的Shader中CGIncludes文件夹中中主要包含的文件以及它们的主要用处:

文件名 描述
UnityCG.cginc 包含了最常使用的帮助函数、宏和结构体等
UnityShader Variables.cginc 在编译Unity Shader 时,会被自动包含进来。包含了许多内置的全局变量,如UNITY_MATRIX MVP等
Lighting.cginc 包含了各种内置的光照模型,如果编写的是Surface Shader的话,会自动包含进来
HLSLSupport.cginc 在编译Unity Shader时,会被自动包含进来。声明了很多用于跨平台编译的宏和定义

UnityCG.cginc是我们最常接触的文件,我们可以直接使用其中预定义的结构体作为顶点着色器的输入和输出。

名称 描述 包含的变量
appdata_base 可用于顶点着色器的输入 顶点位置、顶点法线、第一组纹理坐标
appdata_tan 可用于顶点着色器的输入 顶点位置、顶点切线、顶点法线、第一组纹理坐标
appdata_full 可用于顶点着色器的输入 顶点位置、顶点切线、顶点法线、四组(或更多)纹理坐标
appdata_img 可用于顶点着色器的输入 顶点位置、第一组纹理坐标
v2f_img 可用于顶点着色器的输出 裁剪空间中的位置、纹理坐标

UnityCG.cginc,还提供了许多常见的帮助函数以及用于访问时间、光照、雾效和环境光等目的的变量。

4.3 - Unity支持的语义

应用阶段传递模型数据给顶点着色器时Unity支持的语义:

语义 描述
POSITION 模型空间中的顶点位置,通常是float4类型
NORMAL 顶点法线,通常是float3类型
TANGENT 顶点切线,通常是float4类型
TEXCOORDn,如TEXCOORD0、TEXCOORD1 该顶点的纹理坐标,TEXCOORD0表示第一组纹理坐标,依此类推。通常是float2或float4类型
COLOR 顶点颜色,通常是 fixed4或float4类型

其中 TEXCOORDn 中n的数目是和 Shader Model有关的,例如一般在 Shader Model 2(即Unity默认编译到的Shader Model 版本)和Shader Model 3中,n等于8,而在Shader Model 4和Shader Model 5中,n等于16。通常情况下,一个模型的纹理坐标组数一般不超过2,即我们往往只使用TEXCOORDO和 TEXCOORD1。在Unity内置的数据结构体appdata_full 中,它最多使用了6个坐标纹理组。

顶点着色器传递模型数据给片元着色器时Unity支持的语义:

语义 描述
SV_POSITION 裁剪空间中的顶点坐标,结构体中必须包含一个用该语义修饰的变量。等同于DirectX 9中的POSITION,但最好使用SV_POSITION
COLOR 0 通常用于输出第一组顶点颜色,但不是必需的
COLOR 1 通常用于输出第二组顶点颜色,但不是必需的
TEXCOORD0~TEXCOORD7 通常用于输出纹理坐标,但不是必需的

上面的语义中,除了SV_POSITION是有特别含义外,其他语义对变量的含义没有明确要求,
也就是说,我们可以存储任意值到这些语义描述变量中。通常,如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选用TEXCOORDO等。

片元着色器输出时Unity支持的语义:

语义 描述
SV_Target 输出值将会存储到渲染目标(render target)中。等同于DirectX 9中的COLOR语义,但最好使用SV_Target

需要注意的是,一个语义可以使用的寄存器只能处理4个浮点值(float)。因此,如果我们想要定义矩阵类型,如 float3×4float4×4等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对于float4×4的矩阵类型,我们可以拆分成4个float4类型的变量,每个变量存储了矩阵中的一行数据。

4.4 - Debug

  • 使用假彩色图片
  • 使用Visual Studio中的Graphics Debugger
  • 使用Unity中的帧调试器

4.4.1 - 帧调试器

Unity中的帧调试器可以让我们看到游戏图像中的某一帧是如何一步步渲染出来的。

可以从Window -> Analysis -> Frame Debugger打开帧调试器。

帧调试器可以用于查看渲染该帧时进行的各种渲染事件(event),这些事件包含了Draw Call序列,也包括了类似清空帧缓存等操作。帧调试器窗口大致可分为3个部分:最上面的区域可以开启/关闭(单击 Enable 按钮)帧调试功能,当开启了帧调试时,通过移动窗口最上方的滑动条(或单击前进和后退按钮),我们可以重放这些渲染事件;
左侧的区域显示了所有事件的树状图,在这个树状图中,每个叶子节点就是一个事件,而每个父节点的右侧显示了该节点下的事件数目。我们可以从事件的名字了解这个事件的操作,例如以 Draw开头的事件通常就是一个Draw Call;当单击了某个事件时,在右侧的窗口中就会显示出该事件的细节,例如几何图形的细节以及使用了哪个Shader等。同时在Game视图中我们也可以看到它的效果。如果该事件是一个 Draw Call并且对应了场景中的一个GameObject,那么这个GameObject也会在Hierarchy视图中被高亮显示出来。

如果被选中的Draw Call是对一个渲染纹理(RenderTexture)的渲染操作,那么这个渲染纹理就会显示在 Game视图中。而且,此时右侧面板上方的工具栏中也会出现更多的选项,例如在Game视图中单独显示R、G、B和A通道。
Unity提供的帧调试器实际上并没有实现一个真正的帧拾取(frame capture)功能,而是仅仅使用停止渲染的方法来查看渲染事件的结果。例如,如果我们想要查看第4个Draw Call 的结果,那么帧调试器就会在第4个 Draw Call 调用完毕后停止渲染。这种方法虽然简单,但得到的信息也很有限。如果想要获取更多的信息,还是需要使用外部工具,例如VisualStudio 插件,或者 Intel GPA、RenderDoc、NVIDIA NSight、AMD GPU PerfStudio等工具。

4.5 - 小心:渲染平台的差异

4.5.1 - 渲染纹理的坐标差异

OpenGL和DirectX的屏幕空间坐标存在差异,但是Unity会在背后为我们处理,翻转屏幕图像纹理,以达到在不同平台的一致性。

  • 有一种情况下Unity不会为我们进行这个反转操作,这种情况是我们开启了抗锯齿。

    Edit -> Project Settings -> Quality -> Anti Aliasing中开启

在这种种情况下,Unity 首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时,在 DirectX平台下,我们得到的输入屏幕图像并不会被Unity翻转,也就是说,此时对屏幕图像的采样坐标是需要符合 DirectX平台规定的。如果我们的屏幕特效只需要处理一张渲染图像,我们仍然不需要在意纹理的翻转问题,这是因为在我们调用Graphics.Blt函数时,Unity已经为我们对屏幕图像的采样坐标进行了处理,我们只需要按正常的采样过程处理屏幕图像即可。但如果我们需要同时处理多张渲染图像(前提是开启了抗锯齿),例如需要同时处理屏幕图像和法线纹理,这些图像在竖直方向的朝向就可能是不同的(只有在 DirectX这样的平台上才有这样的问题)。这种时候,我们就需要自己在顶点着色器中翻转某些渲染纹理(例如深度纹理或其他由脚本传递过来的纹理)的纵坐标,使之都符合DirectX平台的规则。例如:

1
2
3
4
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0)
uv.y= 1-uv.y;
#endif

其中,UNITY_UV_STARTS_AT_TOP 用于判断当前平台是否是DirectX类型的平台,而当在这样的平台下开启了抗锯齿后,主纹理的纹素大小在竖直方向上会变成负值,以方便我们对主纹理进行正确的采样。因此,我们可以通过判断 MainTex_TexelSize.x是否小于0来检验是否开启了抗锯齿。如果是,我们就需要对除主纹理外的其他纹理的采样坐标进行竖直方向上的翻转。

在本书资源的项目中,我们开启了抗锯齿选项。在第12章中,我们将学习一些基本的屏幕后处理效果。这些效果大多使用了单张屏幕图像进行处理,因此我们不需要考虑平台差异化的问题,因为Unity已经在背后为我们处理过了。但在12.5节中,我们需要在一个Pass 中同时处理屏幕图像和提取得到的亮部图像来实现Bloom效果。由于需要同时处理多张纹理,因此在 DirectX这样的平台下如果开启了抗锯齿,主纹理和亮部纹理在竖直方向上的朝向就是不同的,我们就需要对亮部纹理的采样坐标进行翻转。在第13章中,我们需要同时处理屏幕图像和深度/法线纹理来实现一些特殊的屏幕效果,在这些处理过程中,我们也需要进行
一些平台差异化处理。在15.3节中,尽管我们也在一个 Pass中同时处理了屏幕图像、深度纹理和一张噪声纹理,但我们只对深度纹理的采样坐标进行了平台差异化处理,而没有对噪声纹理进行处理。这是因为,类似噪声纹理的装饰性纹理,它们在竖直方向上的朝向并不是很重要,即便翻转了效果往往也是正确的,因此我们可以不对这些纹理进行平台差异化处理。

除此之外,还有Shader语法和语义的差异。

4.5.2 - 语法差异

  • 在表面着色器的顶点函数中,用了一个没有初始化的out修饰符参数

    可以使用以下代码对这些参数进行初始化:

    1
    2
    3
    4
    5
    void vert (inout appdata full v, out Input o){
    //使用Unity内置的UNITY_INITIALIZE_OUTPUT宏对输出结构体o进行初始化
    UNITY_INITIALIZE__OUTPUT ( Input, o);
    //.…
    }
  • DirectX 9/11 不支持在顶点着色器中使用tex2D函数,因为在这个阶段Shader无法得到UV的偏导,而tex2D函数需要得到这个骗到,因此我们需要使用tex2Dlod函数来替代

    1
    tex2Dlod(tex, float4(uv, 0,0)).

4.5.3 - 语义差异

我们应该遵循以下语义来描述Shader的输入和输出变量

  • 使用SV_POSITION来描述顶点着色器输出的顶点位置。一些Shader使用了 POSITION 语义,但这些Shader无法在索尼PS4平台上或使用了细分着色器的情况下正常工作。
  • 使用SV_Target来描述片元着色器的输出颜色。一些Shader使用了COLOR或者COLOR0语义,同样的,这些Shader无法在索尼PS4上正常工作。

4.6 - Shader简洁之道

  • 根据平台选择合适的精度
  • 规范语法
  • 避免不必要的计算
  • 慎用分支和循环语句
  • 不要除以0

4.6.1- 精度的选择

在CG/HLSL中,以下几种精度数值:

类型 精度
float 最高精度的浮点值。通常使用32位来存储
half 中等精度的浮点值。通常使用16位来存储,精度范围是-60000~+60 000
fixed 最低精度的浮点值。通常使用11位来存储,精度范围是-2.0~+2.0
  • 大多数现代的桌面GPU 会把所有计算都按最高的浮点精度进行计算,也就是说,float、half、 fixed在这些平台上实际是等价的。这意味着,我们在PC上很难看出因为 half和fixed精度而带来的不同。
  • 但在移动平台的GPU上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader 。
  • fixed精度实际上只在一些较旧的移动平台上有用,在大多数现代的GPU上,它们内部把fixed和 half当成同等精度来对待。

尽管有上面的不同,但一个基本建议是,尽可能使用精度较低的类型,因为这可以优化 Shader的性能,这一点在移动平台上尤其重要。从它们大体的值域范围来看,我们可以使用fixed类型来存储颜色单位矢量,如果要存储更大范围的数据可以选择half类型,最差情况下再选择使用float。如果我们的目标平台是移动平台,一定要确保在真实的手机上测试我们的Shader,这一点非常重要。

4.6.2 - 避免不必要的计算

当出现以下报错时:

1
temporary register limit of 8 exceeded

1
Arithmetic instruction limit of 64 exceeded; 65 arithmetic instructions needed to compileprogram

出现这些错误信息大多是因为我们在Shader中进行了过多的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目。需要知道的是,不同的Shader Target不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的。
通常,我们可以通过指定更高等级的 Shader Target来消除这些错误。

指令 描述
#pragma target 2.0 默认的 Shader Target等级。相当于Direct3D9上的Shader Model 2.0
#pragma target 3.0 相当于 Direct3D 9上的 Shader Model 3.0
#pragma target 4.0 相当于Direct3D 10上的Shader Model 4.0。目前只在 DirectX 11和 XboxOne/PS4平台上提供了
#pragma target 5.0 相当于Direct3D 11上的Shader Model 5.0。目前只在 DirectX 11和XboxOne/PS4平台上提供了

需要注意的是,所有类似OpenGL 的平台(包括移动平台)被当成是支持到Shader Model 3.0的。而WP8/WinRT平台则只支持到Shader Model 2.0。

  • 什么是 Shader Model 呢?

  • Shader Model是由微软提出的一套规范,通俗地理解就是它们决定了Shader中各个特性(feature)能力(capability)。这些特性和能力体现在Shader能使用的运算指令数目、寄存器个数等各个方面。Shader Model等级越高,Shader的能力就越大。

虽然更高等级的Shader Target可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少Shader 中的运算,或者通过预计算的方式来提供更多的数据。

4.6.3- 慎用分支和循环语句

如果我们在Shader中使用了大量的流程控制语句,那么这个 Shader的性能可能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上端移动,例如把放在片元着色器中的计算放到顶点着色器中,或者直接在CPU中进行预计算,再把结果传递给Shader。当然,有时我们不可避免地要使用分支语句来进行运算,那么一些建议是:

  • 分支判断语句中使用的条件变量最好是常数,即在Shader运行过程中不会发生变化;
  • 每个分支中包含的操作指令数尽可能少;
  • 分支的嵌套层数尽可能少。

5 - Unity 中的基础光照

如何决定一个像素的颜色,从宏观上看,渲染包含两大部分:

  • 决定一个像素的可见性
  • 决定这个像素上的光照计算

光照模型就是用于决定这个像素上的光照计算

5.1 - 模拟现实中的光照环境

要模拟真实的光照环境,需要考虑一下三种物理现象:

  • 光线从光源中被发射出来
  • 光线与场景中的物体相交,一部分被吸收,一部分被散射
  • 摄像机吸收光线,产生了图像

5.1.1 - 光源

在光学中,我们使用辐照度(irradiance)来量化光。计算物体表明的辐照度可以使用光源方向l表面法线n之间的夹角的余弦值来得到。

5.1.2 - 吸收和散射

光线由光源发射出来后,就会与一些物体相交。通常,相交的结果有两个:散射(scattering)吸收(absorption)
散射只改变光线的方向,但不改变光线的密度和颜色。而吸收只改变光线的密度和颜色,但不改变光线的方向。

光线在物体表面经过散射后,有两种方向:一种将会散射到物体内部,这种现象被称为折射(refraction)透射(transmission);另一种将会散射到外部,这种现象被称为**反射(reflection)**。

对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒进行相交,其中一些光线最后会重新发射出物体表面,而另一些则被物体吸收。那些从物体表面重新发射出的光线将具有和入射光线不同的方向分布和颜色。

为了区分两种不同的散射方向,我们在光照模型中使用了不同的部分来计算它们:高光反射(specular)部分表示物体表面是如何反射光线的,而漫反射(diffuse)部分则表示有多少光线会被折射、吸收和散射出表面。

5.1.3 - 着色

着色指的是根据材质属性、光源信息,使用一个等式去计算某个观察方向的出射度的过程,也成为光照模型。

5.1.3 - BRDF光照模型

当光线从某个点照射到一个表面时,有多少光线被反射,反射的方向有哪些,BRDF(Bidirectional Reflectance Distribution Function)光照模型就是用来回答这些问题的。

在图形学中,BRDF 大多使用一个数学公式来表示,并且提供了一些参数来调整材质属性。通俗来讲,当给定入射光线的方向和辐照度后,BRDF可以给出在某个出射方向上的

5.2 - 标准光照模型

5.2.1 - Phong光照模型

  • 自发光:这个部分用于描述当给定一个方向时,一个表面本身会向该方向发射多少辐射量。

    需要注意的是,如果没有使用**全局光照(global illumination)**技术,这些自发光的表面并不会真的照亮周围的物体,而是它本身看起来更亮了而已。

  • 高光反射:这个部分用于描述当光线从光源照射到模型表面时,该表面会在完全镜面反射方向散射多少辐射量。

  • 漫反射:这个部分用于描述,当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。

    漫反射符合兰伯特定律:反射光线的强度与表面法线和光源方向之间的夹角的余弦值成正比

    需要注意的是,我们需要防止法线和光源方向点乘的结果为负值,为此,我们使用取最大值的函数来将其截取到0,这可以防止物体被从后面来的光源照亮。

  • 环境光:它用于描述其他所有的间接光照。

5.2.2 - Blinn-Phong光照模型

Blinn提出了简单的修改办法来得到类似的高光反射效果。在硬件实现时,如果摄像机和光源距离模型足够远的话,Blinn模型会快于Phong模型。

需要注意的是,这两种光照模型都是经验模型。

5.2.3 - 逐像素还是逐顶点光照

以上的光照模型可以在顶点着色器中计算,称为逐顶点光照(per-vertex lighting),也可以在片元着色器中计算,称为逐像素光照(per-pixel lighting)。

逐像素光照中,我们会以每个像素为基础,得到它的法线(可以是对顶点法线插值得到的,也可以是从法线纹理中采样得到的),然后进行光照模型的计算。这种在面片之间对顶点法线进行插值的技术被称为Phong着色(Phong shading),也被称为Phong插值或法线插值着色技术。这不同于我们之前讲到的Phong光照模型。

与之相对的是逐顶点光照,也被称为高洛德着色(Gouraud shading)。在逐顶点光照中,我们在每个顶点上计算光照,然后会在渲染图元内部进行线性插值,最后输出成像素颜色。由于顶点数目往往远小于像素数目,因此逐顶点光照的计算量往往要小于逐像素光照。但是,由于逐顶点光照依赖于线性插值来得到像素光照,因此,当光照模型中有非线性的计算(例如计算高光反射时)时,逐顶点光照就会出问题。在后面的章节中,我们将会看到这种情况。而且,由于逐顶点光照会在渲染图元内部对顶点颜色进行插值,这会导致渲染图元内部的颜色总是暗于顶点处的最高颜色值,这在某些情况下会产生明显的棱角现象。

但这种模型有很多局限性。首先,有很多重要的物理现象无法用Blinn-Phong模型表现出来,例如菲涅耳反射(Fresnel reflection)。其次,Blinn-Phong模型各项同性(isotropic)的,也就是说,当我们固定视角和光源方向旋转这个表面时,反射不会发生任何改变。但有些表面是具有各向异性(anisotropic)反射性质的,例如拉丝金属、毛发等。

5.3 - Unity中的环境光和自发光

在标准光照模型中,环境光和自发光的计算是最简单的。

  • 在Unity 中,场景中的环境光可以在Window ->Lighting ->Ambient SourcelAmbient ColorlAmbientIntensity中控制
  • 在 Shader中,我们只需要通过Unity 的内置变量UNITY_LIGHTMODEL_AMBIENT就可以得到环境光的颜色和强度信息。

5.4 - 在Unity中实现漫反射光照模型

漫反射的计算公式:
$$
c_{diffuse} = (c_{light} \cdot m_{diffuse})max(0, \hat{n} \cdot I)
$$
从公式可以看出,要计算漫反射需要知道4个参数:

  • 入射光线的颜色和强度c_light
  • 材质的漫反射系数m_diffuse
  • 表面法线n
  • 光源方向I

为了防止点积结果为负值,我们需要使用max操作,而CG提供了这样的函数。在本例中,使用CG的另一个函数可以达到同样的目的,即saturate函数。

5.4.1 - 逐顶点光照的实现

  1. 为Shader改名

  2. 为了得到漫反射颜色,声明Color类型的属性**_Diffuse**,用于控制漫反射颜色

    1
    2
    3
    4
    Properties
    {
    _Diffuse ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    }
  3. 在Pass中指定光照模式

    1
    Tags { "LightMode" = "ForwardBase" }
  4. 包含Unity内置文件Lighting.cginc

  5. 定义变量,与属性中声明的变量匹配

  6. 在appdata和v2f中定义相应的变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    struct appdata
    {
    float4 vertex : POSITION;
    fixed3 normal : NORMAL;
    };

    struct v2f
    {
    float4 vertex : SV_POSITION;
    fixed3 color : COLOR;
    };
  7. 在顶点着色计算漫反射

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);

    // 获取环境光
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    // 将法线方向从物体空间转为剪裁空间
    fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
    // 获取世界空间下的光线方向
    fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);

    // 计算漫反射
    fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLight));
    o.color = ambient + diffuse;

    return o;
    }
  8. 因为计算都在顶点着色器中完成,片元着色器只要返回颜色值。

    1
    2
    3
    4
    fixed4 frag (v2f i) : SV_Target
    {
    return fixed4(i.color, 1.0);
    }
  9. 最后添加回调函数

    1
    FallBack "Diffuse"

5.4.2 - 逐像素光照的实现

逐像素光照是在逐顶点光照的基础上,将计算都移到片元着色器中实现。

顶点着色器和片元着色器的输入:

1
2
3
4
5
6
7
8
9
10
11
struct appdata
{
float4 vertex : POSITION;
fixed3 normal : NORMAL;
};

struct v2f
{
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};

顶点着色器中只计算法线方向

1
2
3
4
5
6
7
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
return o;
}

在片元着色器中完成所有计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fixed4 frag (v2f i) : SV_Target
{
// 获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

// 将法线方向从物体空间转为剪裁空间
fixed3 worldNormal = normalize(i.worldNormal);
// 获取世界空间下的光线方向
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);

// 计算漫反射
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLight));

fixed3 color = ambient + diffuse;

return fixed4(color, 1.0);

}

5.4.3 - 半兰伯特模型

半兰伯特模型的计算公式如下:
$$
c_{diffuse} = (c_{light} \cdot m_{diffuse})(0.5(0, \hat{n} \cdot I)+0.5)
$$

这个模型没有使用Max函数来防止光线为负,而是将n·I的结果从[-1,1]映射到[0,1]的范围。

这样做的好处是能够使得背光面有明暗变化,而不是死黑。

在逐像素光照的基础上,将saturate的操作更换成以下操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fixed4 frag (v2f i) : SV_Target
{
// 获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

// 将法线方向从物体空间转为剪裁空间
fixed3 worldNormal = normalize(i.worldNormal);
// 获取世界空间下的光线方向
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
// 计算漫反射
fixed halfLambert = dot(worldNormal,worldLight) * 0.5 + 0.5;

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;

fixed3 color = ambient + diffuse;

return fixed4(color, 1.0);

}

5.5 - 在Unity中实现高光反射模型

高光反射的计算公式如下:
$$
c_{specular} = (c_{light} \cdot m_{specular})max(0, \hat{v} \cdot r)^{m_{gloss}}
$$
从上述公式可以得出,计算高光反射需要4个参数:

  • 入射光线的颜色和强度c_light
  • 材质的高光反射系数m_specular
  • 视角方向的单位向量v
  • 反射方向r

反射方向r可以由表面法线的单位向量n和光源方向的单位向量I得到,

可以直接使用CG提供的函数reflect(i,n)计算得出

5.5.1 - 逐顶点光照的实现

  1. 首先,在属性声明三个属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _Diffuse ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
    _Gloss ("", Range(8.0, 256)) = 20
    //高光区域大小
    }

    其中,Specular用于控制高光反射的颜色,Gloss用于控制高光区域的大小

  2. 然后指明光照模式

    1
    Tags { "LightMode" = "ForwardBase" }
  3. 指明我们定义的顶点和片元着色器,同时将"Lighting.cginc"包含进来

  4. 声明属性变量,并且定义顶点和片元着色器的输入结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    struct appdata
    {
    float4 vertex : POSITION;
    fixed3 normal : NORMAL;
    };

    struct v2f
    {
    float4 vertex : SV_POSITION;
    float3 color : COLOR;
    };
  5. 在顶点着色器中计算高光反射:

    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
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    // 获取环境光
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    // 将法线方向从物体空间转为剪裁空间
    fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
    // 获取世界空间下的光线方向
    fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
    //计算漫反射系数
    fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLight));

    //从世界空间获取反射方向
    fixed3 reflectDir = normalize(reflect(-worldLight, worldNormal));
    //从世界空间获取视觉方向
    fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);
    //计算高光系数
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)),_Gloss);

    o.color = ambient + diffuse + specular;
    return o;
    }
    • 漫反射的部分和上节提到的完全一致,对于高光反射部分,我们首先计算入射光线方向关于表面法线的反射方向reflectDir。由于CG的reflect函数的入射方向要求是由光源指向交点处的,因此我们需要对 worldLightDir 取反后再传给reflect函数。

    • 然后,我们通过**_WorldSpaceCameraPos得到了世界空间中的摄像机位置,再把顶点位置从模型空间变换到世界空间下,再通过和_WorldSpaceCameraPos** 相减即可得到世界空间下的视角方向。

  6. 在片元着色器中输出颜色

    1
    2
    3
    4
    fixed4 frag (v2f i) : SV_Target
    {
    return fixed4(i.color, 1.0);
    }
  7. 最后设置回调函数

    1
    FallBack "Specular"

5.5.2 - 逐像素光照的实现

  1. 在逐顶点光照的基础上,修改输入结构体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct appdata
    {
    float4 vertex : POSITION;
    fixed3 normal : NORMAL;
    };

    struct v2f
    {
    float4 vertex : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;
    };
  2. 在顶点着色器中,我们只要计算世界空间下的法线方向以及顶点坐标,并将它们传递给片元着色器即可

    1
    2
    3
    4
    5
    6
    7
    8
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex);
    return o;
    }
  3. 在片元着色器中计算光照模型:

    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
    fixed4 frag (v2f i) : SV_Target
    {

    // 获取环境光
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    // 将法线方向从物体空间转为剪裁空间
    fixed3 worldNormal = normalize(i.worldNormal);
    // 获取世界空间下的光线方向
    fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
    //计算漫反射系数
    fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLight));

    //从世界空间获取反射方向
    fixed3 reflectDir = normalize(reflect(-worldLight, worldNormal));
    //从世界空间获取视觉方向
    fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    //计算高光系数
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)),_Gloss);

    fixed3 color = ambient + diffuse + specular;


    return fixed4(color, 1.0);
    }

    在从世界空间获取反射方向r中,CG的reflect函数的入射方向要求是由光源指向交点处的,因此我们需要对 worldLightDir 取反后再传给reflect函数。

逐像素处理可以得到更加平滑的高光效果,至此,我们得到了一个完整的Phong光照模型

5.5.3 - Blinn-Phong光照模型

Blinn模型没有使用反射方向r,而是引入一个新的矢量h:
$$
\hat{h} = \frac{\hat{v} + \hat{I}}{\mid \hat{v} + \hat{I} \mid }
$$
而Blinn模型计算高光反射的公式如下:
$$
c_{specular} = (c_{light} \cdot m_{specular})max(0, \hat{n} \cdot \hat{h})^{m_{gloss}}
$$

代码的实现只要修改逐像素光照中片元着色器的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fixed4 frag (v2f i) : SV_Target
{
...

//从世界空间获取视觉方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
//从世界空间获取半方向
fixed3 halfDir = normalize(worldLight + viewDir);
//计算高光系数
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal, halfDir)),_Gloss);

fixed3 color = ambient + diffuse + specular;


return fixed4(color, 1.0);
}

使用法线向量nhalfDir进行点积,最后将环境光、漫反射、高光反射相加得到颜色。

Blinn-Phong光照模型的高光反射部分看起来更大、更亮一些,在实际的渲染中,我们也会选择Blinn-Phong光照模型

5.6 - Unity内置函数

在计算光照模型的时候,我们往往需要得到光源方向、视角方向这两个基本信息。我们在上面的例子中得到的光源方向,这种办法只适用于平行光,如果需要处理更复杂的光照模型,如点光源和聚光灯,那么这种计算方法是错误的。我们需要现在代码中先判断光源的类型,再计算它的光源信息。

Unity提供了一些内置函数来帮助我们计算这些信息:

函数名 描述
float3 WorldSpaceViewDir (float4 v) 输入一个模型空间中的顶点位置,返回世界空间中从该点到摄像机的观察方向。内部实现使用了Unity WorldSpace ViewDir函数
float3 Unity WorldSpaceViewDir (float4 v) 输入一个世界空间中的顶点位置,返回世界空间中从该点到摄像机的观察方向
float3 ObjSpaceViewDir (float4 v) 输入一个模型空间中的顶点位置,返回模型空间中从该点到摄像机的观察方向
float3 WorldSpaceLightDir(float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回世界空间中从该点到光源的光照方向。内部实现使用了 Unity WorldSpaceLightDir函数。没有被归一化
float3 UnityWorldSpaceLightDir (float4 v) 仅可用于前向渲染中。输入一个世界空间中的顶点位置,返回世界空间中从该点到光源的光照方向。没有被归一化
float3 ObjSpaceLightDir (float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回模型空间中从该点到光源的光照方向。没有被归一化
float3 UnityObjectToWorldNormal (float3 norm) 把法线方向从模型空间转换到世界空间
float3 UnityObjectToWorldDir (in float3 dir) 把方向矢量从模型空间变换到世界空间
float3 Unity WorldToObjectDir(float3 dir) 把方向矢量从世界空间变换到模型空间

需要注意的是,这些函数都没有保障得到的方向矢量是单位矢量,因此,我们需要在使用前把它们归一化

计算光源方向的3个函数:

  • WorldSpaceLightDir
  • Unity WorldSpaceLightDir
  • ObjSpaceLightDir

它们尽可用于向前渲染,因为只有在向前渲染中,这3个函数中使用的内置变量**_WorldSpaceLightPos0**才会被正确赋值。

以下是在Blinn-Phong光照模型中的使用Unity内置函数的例子:

  • 在顶点着色器中,我们使用UnityObjectToWorldNormal 来计算世界空间下的法线方向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    //o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
    //使用内置函数来计算世界空间中的法线向量
    o.worldNormal = UnityObjectToWorldNormal(v.normal);

    o.worldPos = mul(unity_ObjectToWorld, v.vertex);
    return o;
    }
  • 在片元着色器中,我们使用内置的UnityWorldSpaceLightDir 函数和WorldSpaceViewDir 来计算世界空间中的光照方向和视角方向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    fixed4 frag (v2f i) : SV_Target
    {

    ...

    // 将法线方向从物体空间转为剪裁空间
    fixed3 worldNormal = normalize(i.worldNormal);
    // 获取世界空间下的光线方向
    fixed3 worldLight = normalize(UnityWorldSpaceLightDir(i.worldPos));
    //fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
    ...

    ...

    //从世界空间获取视角方向
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    //fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    ...
    }

以上的方向矢量都需要使用normalize进行归一化得到单位矢量。

6 - 基础纹理

在美术人员建模的时候,通常会在建模软件中利用纹理展开技术把纹理映射坐标(texture-mapping coordinates)存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维变量(u, v)来表示,其中u是横向坐标,而v是纵向坐标。因此,纹理映射坐标也被称为UV坐标

Unity使用的纹理空间是符合OpenGL风格的。

6.1 - 单张纹理

6.1.1 - 单张纹理的实现

  1. 为了使用纹理,在Properties中添加以下属性

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _Color ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _MainTex ("Texture", 2D) = "white" {}
    _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
    _Gloss ("", Range(8.0, 256)) = 20
    }
  2. 指明光照模式、声明变量、包含内置文件

    与其他属性类型不同的是,我们还需要为纹理类型的属性声明一个 float4类型的变量**_MainTex_ST。其中,_MainTex_ST的名字不是任意起的。在 Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中,ST缩放(scale)平移( translation)的缩写。_MainTex_ST可以让我们得到该纹理的缩放和平移(偏移)值,_MainTex_ST.xy** 存储的是缩放值,而**_MainTex ST.zw**存储的是偏移值。这些值可以在材质中调节。

  3. 定义顶点着色器的输入和输出结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    struct appdata
    {
    float4 vertex : POSITION;
    fixed3 normal : NORMAL;
    float2 uv : TEXCOORD0;
    };

    struct v2f
    {
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD2;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;

    };
    • 在上面的代码中,我们首先在a2v结构体中使用TEXCOORDO语义声明了一个新的变量uv,这样Unity就会将模型的第一组纹理坐标存储到该变量中。

    • 然后,我们在v2f结构体中添加了用于存储纹理坐标的变量uv,以便在片元着色器中使用该坐标进行纹理采样。

  4. 定义顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    o.uv = TRANSFORM_TEX(v.uv, _MainTex);

    return o;
    }
    • 在顶点着色器中,我们使用纹理的属性值**_MainTex_ST来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性_MainTex_ST.xy** 对顶点纹理坐标进行缩放,然后再使用偏移属性_Main Tex_ST.zw对结果进行偏移。

    • Unity 提供了一个内置宏 TRANSFORM_TEX来帮我们计算上述过程。TRANSFORM_TEX是在 UnityCGcginc中定义的:

      1
      #define TRANSFORM_TEX(tex, name) {tex.xy * name##_ST,xy + name##_ST,zw}
  5. 实现片元着色器,并在计算漫反射时使用纹理中的纹素值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal, halfDir)),_Gloss);


    fixed3 col = ambient + diffuse + specular;
    return fixed4(col, 1.0);
    }

    上面的代码首先计算了在世界空间下的法线方向光照方向,然后使用CG的tex2D函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,它将返回计算得到的纹素值。我们使用采样结果和颜色属性**_Color** 的乘积来作为材质的反射率albedo,并把它和环境光照相乘得到环境光部分。随后,我们使用albedo来计算漫反射光照的结果,并和环境光照、高光反射光照相加后返回。

  6. 设置合适的回调函数

    1
    FallBack "Specular"

6.1.2 - 纹理的属性

纹理的类型有以下几种

  • Default:默认为纹理类型
  • Normal map:法线类型
  • Cubemap
  • Wrap Mode一共有两种: RepeatClamp。它决定了纹理坐标超过[0, 1]时候纹理将会如何被平铺,Repeat会不断重复,Clamp只会截取临近的部分,如果纹理坐标大于1,那么会截取到1,如果小于0,那么会截取到0;

  • Filter Mode:它决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式。

    Filter Mode支持3种模式:PointBilinear以及 Trilinear。它们得到的图片滤波效果依次提升,但需要耗费的性能也依次增大。纹理滤波会影响放大或缩小纹理时得到的图。

    • 纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素将会对应一个目标像素。纹理缩放更加复杂的原因在于我们往往需要处理抗锯齿问题一个最常使用的方法就是使用多级渐远纹理(mipmapping)技术。

    • 多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像,形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用33%的内存空间。这是一种典型的用空间换取时间的方法。

    • 在 Unity中,我们可以在纹理导入面板中,首先将纹理类型(TextureType)选择成Advanced,再勾选 Generate Mip Maps即可开启多级渐远纹理技术。同时,我们还可以选择生成多级渐远纹理时是否使用线性空间以及采用的滤波器等。

    在内部实现上,Point模式使用了最近邻(nearest neighbor)滤波,在放大或缩小时,它的采样像素数目通常只有一个,因此图像会看起来有种像素风格的效果。而Bilinear滤波则使用了线性滤波,对于每个目标像素,它会找到4个邻近像素,然后对它们进行线性插值混合后得到最终像素,因此图像看起来像被模糊了。而 Trilinear滤波几乎是和 Bilinear一样的,只是 Trilinear还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么Trilinear得到的结果还是和Bilinear是一样的。

  • 纹理的最大尺寸:如果导入的纹理大小超过了Max Texture Size 中的设置值,那么Unity将会把该纹理缩放为这个最大分辨率。理想情况下,导入的纹理可以是非正方形的,但长宽的大小应该是2的幂,
    例如
    2、4、8、16、32、64等。如果使用了非2的幂大小(Non Powerof Two,NPOT)的纹理,那么这些纹理往往会占用更多的内存空间,而且GPU 读取该纹理的速度也会有所下降。有一些平台甚至不支持这种NPOT纹理,这时Unity在内部会把它缩放成最近的2的幂大小。出于性能和空间的考虑,我们应该尽量使用2的幂大小的纹理。

PointBilinear以及 Trilinear下的画面拉伸的图:

image-20230704184957079

6.2 - 凹凸映射

纹理的另一种常见的应用就是凹凸映射(bump mapping)。凹凸映射的目的是使用一张纹理来修改模型表面的法线,以便为模型提供更多的细节。这种方法不会真的改变模型的顶点位置,只是让模型看起来好像是“凹凸不平”的,但可以从模型的轮廓处看出“破绽”。

有两种主要的方法可以用来进行凹凸映射:

  • 一种方法是使用一张高度纹理(height map)来模拟表面位移(displacement),然后得到一个修改后的法线值,这种方法也被称为高度映射(heightmapping);
  • 另一种方法则是使用一张法线纹理(normal map)来直接存储表面法线,这种方法又被称为法线映射(normal mapping)。尽管我们常常将凹凸映射和法线映射当成是相同的技术,但读者需要知道它们之间的不同。

6.2.1 - 高度纹理

高度图中存储的是强度值(intensity),它用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,而颜色越深表明该位置越问里。

这种力云内灯处足非常直观,我们可以从高度图中明确地知道一个模型表画的凹凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,而是需要由像素的灰度值,因此需要消耗更多的性能。

高度图通常会和法线映射一起便用,用于给出表面凹凸的额外信息。也就是说,我们通常会使用法线映射来修改光照。

6.2.2 - 法线纹理

而法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在**[-1,1],而像素的分量范围[0,1],因此我们需要做一个映射,通常使用的映射就是:
$$
pixel = \frac{normal + 1}{2}
$$
这就要求,我们在Shader中对法线纹理进行纹理采样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:
$$
normal = pixel * 2 -1
$$
在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的
切线空间**(tangent space)来存储法线。

对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z轴是顶点的法线方向(n),x轴是顶点的切线方向(t),而y轴可由法线和切线叉积而得,也被称为是副切线(bitangent,b)或副法线,如图7.12所示。

image-20230704192202607

总体来说,使用模型空间来存储法线的优点如下。

  • 实现简单,更加直观。我们甚至都不需要模型原始的法线和切线等信息,也就是说,计算更少。生成它也非常简单,而如果要生成切线空间下的法线纹理,由于模型的切线一般是和UV方向相同,因此想要得到效果比较好的法线映射就要求纹理映射也是连续的。
  • 在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息,因此在边界处通过插值得到的法线可以平滑变换。而切线空间下的法线纹理中的法线信息是依靠纹理坐标的方向得到的结果,可能会在边缘处或尖锐的部分造成更多可见的缝合迹象。

但使用切线空间有更多优点。

  • 自由度很高。模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误了。而切线空间下的法线纹理记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
  • 可进行UV动画。比如,我们可以移动一个纹理的UV坐标来实现一个凹凸移动的效果,但使用模型空间下的法线纹理会得到完全错误的结果。原因同上。这种UV动画在水或者火山眯岩这种类型的物体上会经常用到。
  • 可以重用法线纹理。比如,一个砖块,我们仅使用一张法线纹理就可以用到所有的6个面上。原因同上。
  • 可压缩。由于切线空间下的法线纹理中法线的Z方向总是正方向,因此我们可以仅存储XY方向,而推导得到Z方向。而模型空间下的法线纹理由于每个方向都是可能的,因此必须存储3个方向的值,不可压缩。

切线空间下的法线纹理的前两个优点足以让很多人放弃模型空间下的法线纹理而选择它。从上面的优点可以看出,切线空间在很多情况下都优于模型空间,而且可以节省美术人员的工作。因此,在本书中,我们使用的也是切线空间下的法线纹理。

我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。由于法线纹理中存储的法线是切线空间下的方向,因此我们通常有两种选择:

  • 一种选择是在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下;
  • 另一种选择是在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。

效率上来说,第一种方法往往要优于第二种方法,因为我们可以在顶点着色器中就完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在片元着色器中实现,这意味着我们需要在片元着色器中进行一次矩阵操作。

但从通用性角度来说,第二种方法要优于第一种方法,因为有时我们需要在世界空间下进行一些计算,例如在使用 Cubemap进行环境映射时,我们需要使用世界空间下的反射方向对Cubemap进行采样。如果同时需要进行法线映射,我们就需要把法线方向变换到世界空间下。

6.2.3 - 在切线空间下计算

基本思路:

  • 顶点着色器中把视角方向光照方向模型空间变换到切线空间

    我们需要知道从模型空间到切线空间的变换矩阵,这个变换矩阵的逆矩阵即从切线空间到模型空间的变换矩阵是非常容易求得的。如果一个变换中仅存在平移旋转变换,那么这个变换的逆矩阵就等于它的转置矩阵,而从模型空间切线空间的变换矩阵就是从切线空间到模型空间的变换矩阵的转置矩阵。

  • 片元着色器中通过纹理采样得到切线空间下的法线,然后再与切线空间下的视角方向、光源方向等计算

实现如下:

  1. 添加属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _Color ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
    _Gloss ("", Range(8.0, 256)) = 20
    _BumpMap ("BumpMap", 2D) = "Bump" {}
    _BumpScale ("BumpScale", Float) = 1.0
    }

    其中,**_Color_Specular_Gloss**都是我们为了实现之前的Blinn-Phong光照模型所需的必要参数

    _BumpMap是我们需要的法线纹理,**_BumpScale**用于控制纹理的凹凸程度

  2. 配置光照模式、声明变量、引入内置文件

  3. 修改顶点着色器的输入结构体

    1
    2
    3
    4
    5
    6
    7
    struct appdata
    {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float4 uv : TEXCOORD0;
    };
    • 我们使用TANGENT语义来描述tangent,即告诉Unity把顶点的切线方向填充到tangent

    • 需要注意的是,和法线方向normal 不同,tangent的类型是float4,而非 float3,这是因为我们需要使用tangent.w分量来决定切线空间中的第三个坐标轴——副切线的方向性。

  4. 修改片元着色器的输入结构体

    1
    2
    3
    4
    5
    6
    7
    struct v2f
    {
    float4 vertex : SV_POSITION;
    float4 uv : TEXCOORD0;
    float3 lightDir : TEXCOORD1;
    float3 viewDir : TEXCOORD2;
    };
  5. 定义顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);


    //为了减少寄存器的使用,将贴图存储到一个纹理坐标中
    o.uv.xy = v.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
    o.uv.zw = v.uv.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

    // o.uv.xy = TRANSFORM_TEX(v.uv.xy, _MainTex);
    // o.uv.zw = TRANSFORM_TEX(v.uv.xy, _BumpMap);

    TANGENT_SPACE_ROTATION;

    o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
    o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;

    return o;
    }
    • 由于我们使用了两张纹理,我们使用float4类型的 uv去保存它们,其中xy分量存储**_Main的Tex纹理坐标,zw分量存储_BumpMap**纹理坐标。这样做的好处是可以减少插值寄存器的使用数目。

    • 然后我们使用Unity内置的来计算得到从模型空间到切线空间的变换矩阵 rotation

    • 然后,我们使用Unity的内置函数ObjSpaceLightDirObjSpaceViewDir来得到模型空间下的光照和视角方向,再使用变换矩阵将它们从模型空间变换到切线空间中。

  6. 我们在顶点着色器中完成了大部分工作,在片元着色器中只需要采样得到切线空间下的法线方向,再在切线空间下进行光照计算即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 tangentLightDir = normalize(i.lightDir);
    fixed3 tangentViewDir = normalize(i.viewDir);

    fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
    fixed3 tangentNormal;

    tangentNormal = UnpackNormal(packedNormal);
    tangentNormal.xy *= _BumpScale;
    tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));

    fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss);

    return fixed4(ambient + diffuse + specular, 1.0);

    }

    我们先用tex2D对法线纹理**_BumpMap**进行采样。因为法线纹理中存储的是把法线经过映射得到的像素值,因此我们需要把它们反映射回来。

    我们需要在Unity中把这个法线纹理设置为Normal map,然后只用Unity的内置函数UnpackNormal来获得正确的法线方向。

    使用**_BumpScale对法线的值进行缩放,由于法线都是单位矢量,因此tangentNormal.z分量可以由tangentNormal.xy**计算而得。由于我们使用的是切线空间下的法线纹理,因此可以保证法线方向的z分量为正。

  7. 最后添加合适的回调函数

    1
    FallBack "Specular"

6.2.4 - 在世界空间下计算

基本思路:

  • 在顶点着色器中计算从切线空间到世界空间的变换矩阵,然后将它传递给片元着色器
  • 在片元着色器中把法线纹理中的法线方向切线空间变换到世界空间

尽管这种方法需要更多的计算,但在需要使用Cubemap进行环境映射的情况下,我们就需要这种方法。

实现如下:

  1. 复制之前的代码,修改顶点着色器的输出v2f

    1
    2
    3
    4
    5
    6
    7
    8
    struct v2f
    {
    float4 vertex : SV_POSITION;
    float4 uv : TEXCOORD0;
    float4 TtoW0 : TEXCOORD1;
    float4 TtoW1 : TEXCOORD2;
    float4 TtoW2 : TEXCOORD4;
    };

    插值寄存器最多只能存储float4大小的变量,对于矩阵这样的变量,我们可以把它们按行拆成多个变量再进行存储。上面代码中的TtoW0TtoW1TtoW2 就依次存储了从切线空间到世界空间的变换矩阵的每一行。实际上,对方向矢量的变换只需要使用3×3大小的矩阵,也就是说,每一行只需要使用float3类型的变量即可。但为了充分利用插值寄存器的存储空间,我们把世界空间下的顶点位置存储在这些变量的w分量中。

  2. 修改顶点着色器,计算从切线空间到世界空间的变换矩阵

    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
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);


    //为了减少寄存器的使用,将贴图存储到一个纹理坐标中
    o.uv.xy = v.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
    o.uv.zw = v.uv.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

    // o.uv.xy = TRANSFORM_TEX(v.uv.xy, _MainTex);
    // o.uv.zw = TRANSFORM_TEX(v.uv.xy, _BumpMap);

    float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
    fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
    fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;

    o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
    o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
    o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);


    return o;
    }
  3. 修改片元着色器

    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
     fixed4 frag (v2f i) : SV_Target
    {
    float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);

    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

    fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
    bump.xy *= _BumpScale;
    bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));


    bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));

    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));

    fixed3 halfDir = normalize(lightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump, halfDir)), _Gloss);

    return fixed4(ambient + diffuse + specular, 1.0);

    }
    • 我们首先从TtoW0TtoW1TtoW2w分量中构建世界空间下的坐标。
    • 然后,使用内置的UnityWorldSpaceLightDirUnityWorldSpaceViewDir函数得到世界空间下的光照和视角方向。
    • 接着,我们使用内置的 UnpackNormal函数对法线纹理进行采样和解码(需要把法线纹理的格式标识成Normal map),并使用 BumpScale对其进行缩放。
    • 最后,我们使用TtoW0、TtoW1和 TtoW2存储的变换矩阵把法线变换到世界空间下。这是通过使用点乘操作来实现矩阵的每一行和法线相乘来得到的。

从视觉表现上,在切线空间下和在世界空间下计算光照几乎没有任何差别。

6.2.5 - Unity中的法线纹理类型

当把法线纹理的纹理类型标识成Normal map时,可以使用 Unity的内置函数 UnpackNormal来得到正确的法线方向

当我们需要使用那些包含了法线映射的内置的Unity Shader时,必须把使用的法线纹理按上面的方式标识成Normal map才能得到正确结果(即便你忘了这么做,Unity 也会在材质面板中提醒你修正这个问题),这是因为这些Unity Shader都使用了内置的UnpackNormal 函数来采样法线方向。那么,当我们把纹理类型设置成 Normal map时到底发生了什么呢?为什么要这么做呢?

简单来说,这么做可以让Unity 根据不同平台对法线方向时,需要把纹理类型标识为Normal map纹理进行压缩(例如使用 DXT5nm格式),再通过 UnpackNormal函数来针对不同的压缩格式对法线纹理进行正确的采样。我们可以在 UnityCGcginc里找到UnpackNormal函数的内部实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
fixed3 normal;
normal.xy=packednormal .wy * 2-1;
normal.z = sqrt(l- saturate(dot(normal.xy, normal.xy)));
return normal;
}

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined (UNITY NO DXT5nm)
return packednormal.xyz *2- 1;
#else
return UnpackNormalDXT5nm(packednormal);
#endif
}

从代码中可以看出,在某些平台上由于使用了DXT5nm的压缩格式,因此需要针对这种格式对法线进行解码。在 DXT5nm格式的法线纹理中,纹素的a通道(即w分量)对应了法线的r分量g通道对应了法线的y分量,而纹理的rb通道则会被舍弃,法线的z分量可以由xy分量推导而得。

为什么之前的普通纹理不能按这种方式压缩,而法线就需要使用DXT5nm格式来进行压缩呢?这是因为,按我们之前的处理方式,法线纹理被当成一个和普通纹理无异的图,但实际上,它只有两个通道是真正必不可少的,因为第三个通道的值可以用另外两个推导出来(法线是单位向量,并且切线空间下的法线方向的z分量始终为正)。使用这种压缩方法就可以减少法线纹理占用的内存空间。

当我们把纹理类型设置成Normal map后,还有一个复选框是Create from Grayscale,这个复选框就是用于从高度图中生成法线纹理的高度图本身记录的是相对高度,是一张灰度图白色表示相对更高,黑色表示相对更低。当我们把张高度图导入Unity后,除了需要把它的纹理类型设置成Normal map外,还需要勾选 Create from Grayscale,然后,我们就可以把它和切线空间下的法线纹理同等对待了。

当勾选了Create from Grayscale后,还多出了两个选项——BumpinessFiltering。其中Bumpiness用于控制凹凸程度,而Filtering 决定我们使用哪种方式来计算凹凸程度,它有两种选项:一种是Smooth,这使得生成后的法线纹理会比较平滑;另一种是Sharp,它会使用Sobel滤波(一种边缘检测时使用的滤波器)来生成法线。Sobel滤波的实现非常简单,我们只需要在一个3×3的滤波器中计算x和y方向上的导数,然后从中得到法线即可。具体方法是:对于高度图中的每个像素,我们考虑它与水平方向和竖直方向上的像素差,把它们的差当成该点对应的法线在x和y方向上的位移,然后使用之前提到的映射函数存储成到法线纹理的r和g分量即可。

6.3 - 渐变映射

实现如下:

  1. 在Properties语义块中声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _Color ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _RampTex ("Ramp Tex", 2D) = "white" {}
    _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
    _Gloss ("", Range(8.0, 256)) = 20
    }
  2. 配置光照模式、声明变量、引入内置文件

  3. 修改顶点着色器的输入结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    struct appdata
    {
    float4 vertex : POSITION;
    float4 uv : TEXCOORD0;
    float3 normal : NORMAL;
    };

    struct v2f
    {
    float2 uv : TEXCOORD2;
    float4 vertex : SV_POSITION;

    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;

    };
  4. 修改顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.uv = TRANSFORM_TEX(v.uv, _RampTex);
    return o;
    }
  5. 修改片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    fixed halfLambert = dot(worldNormal,worldLightDir) * 0.5 + 0.5;

    fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb;

    fixed3 diffuse = _LightColor0.rgb * diffuseColor;

    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + viewDir);

    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(0, dot(worldNormal, halfDir))), _Gloss);

    return fixed4(ambient + diffuse + specular, 1.0);
    }
    • 在上面的代码中,我们使用半兰伯特模型,通过对法线方向和光照方向的点积做一次0.5倍的缩放以及一个0.5大小的偏移来计算半兰伯特部分 halfLambert。这样,我们得到的halfLambert的范围被映射到了**[0,1]**之间。
    • 之后,我们使用 halfLambert 来构建一个纹理坐标,并用这个纹理坐标对渐变纹理 RampTex进行采样。由于 RampTex实际就是一个一维纹理(它在纵轴方向上颜色不变),因此纹理坐标的u和v方向我们都使用了halfLambert
    • 然后,把从渐变纹理采样得到的颜色和材质颜色 Color相乘,得到最终的漫反射颜色。剩下的代码就是计算高光反射和环境光,并把它们的结果进行相加。
  6. 最后添加合适的回调函数

    1
    FallBack "Specular"

需要注意的是,我们需要把渐变纹理的 Wrap Mode设为Clamp模式,以防止对纹理进行采样时由于浮点数精度而造成的问题。

使用Repeat模式中在高光区域有一些黑点。这是由浮点精度造成的,当我们使用fixed2(halfLambert,halfLambert)对渐变纹理进行采样时,虽然理论上halfLambert 的值在[0,1]之间,
但可能会有1.000 01这样的值出现。如果我们使用的是
Repeat模式
,此时就会舍弃整数部分,只保留小数部分,得到的值就是0.00001,对应了渐变图中最左边的值,即黑色。因此,就会出现图中这样在高光区域反而有黑点的情况。我们只需要把渐变纹理的Wrap Mode设为Clamp模式就可以解决这种问题。

6.4 - 遮罩映射

遮罩纹理(mask texture)允许我们可以保护某些区域,使它们免于某些修改。

例如,我们希望模型表面某些区域的反光强烈一些,而某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。

另一种常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。

实现如下:

  1. 在Properties语义块中声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _Color ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
    _Gloss ("", Range(8.0, 256)) = 20
    _BumpMap ("BumpMap", 2D) = "Bump" {}
    _BumpScale ("BumpScale", Float) = 1.0
    _SpecularScale ("SpecularScale", Float) = 1.0
    _SpecularMask ("SpecularMask", 2D) = "Bump" {}
    }
  2. 配置光照模式、声明变量、引入内置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    sampler2D _MainTex;
    float4 _MainTex_ST;

    fixed4 _Color;
    sampler2D _BumpMap;
    float _BumpScale;
    sampler2D _SpecularMask;
    float _SpecularScale;
    fixed4 _Specular;
    float _Gloss;

    我们为主要纹理**_MainTex、法线纹理_BumpMap、遮罩纹理_SpecularMask定义了他们共同使用的纹理属性变量_MainTex_ST**。在属性中修改主要纹理的平铺系数和偏移系数会同时影响3个纹理的采样。这样做可以节省需要存储的纹理坐标数目。

  3. 修改顶点着色器的输入结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    struct appdata
    {
    float4 vertex : POSITION;
    float4 uv : TEXCOORD0;
    float3 normal : NORMAL;
    };

    struct v2f
    {
    float2 uv : TEXCOORD2;
    float4 vertex : SV_POSITION;

    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;

    };
  4. 修改顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.uv = TRANSFORM_TEX(v.uv, _RampTex);
    return o;
    }
  5. 修改片元着色器

    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
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 lightDir = normalize(i.lightDir);
    fixed3 viewDir = normalize(i.viewDir);

    //解析法线贴图
    fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
    tangentNormal.xy *= _BumpScale;
    tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));


    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, lightDir));

    fixed3 halfDir = normalize(lightDir + viewDir);


    fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale;
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;

    return fixed4(ambient + diffuse + specular, 1.0);

    }
    • 环境光照和漫反射光照和之前使用过的代码完全一样。在计算高光反射时,我们首先对遮罩纹理**_SpecularMask进行采样。由于本书使用的遮罩纹理中每个纹素的rgb分量其实都是一样的,表明了该点对应的高光反射强度,在这里我们选择使用r分量来计算掩码值。然后,我们用得到的掩码值和_SpecularScale**相乘,一起来控制高光反射的强度。
    • 需要说明的是,我们使用的这张遮罩纹理其实有很多空间被浪费了——它的rgb分量存储的都是同一个值。在实际的游戏制作中,我们往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性。

在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的RGBA四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在R通道,把边缘光照的强度存储在G通道,把高光反射的指数部分存储在B通道,最后把自发光强度存储在A通道。
在游戏《DOTA2》的开发中,开发人员为每个模型使用了4张纹理:一张用于定义模型颜色,一张用于定义表面法线,另外两张则都是遮罩纹理。这样,两张遮罩纹理提供了共8种额外的表面属性,这使得游戏中的人物材质自由度很强,可以支持很多高级的模型属性。

7 - 透明效果

在 Unity中,我们通常使用两种方法来实现透明效果:

  • 第一种是使用透明度测试(Alpha Test),这种方法其实无法得到真正的半透明效果;

    透明度测试:它采用一种“霸道极端”的机制,只要一个片元的透明度不满足条件(通常
    是小于某个阈值),那么它对应的片元就会被舍弃。

    它产生的效果也很极端,要么完全透明,即看不到,要么完全不透明,就像不透明物体那样。

  • 另一种是透明度混合(Alpha Blending)

    透明度混合:这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。

    需要注意的是,透明度混合只关闭了深度写入,但没有关闭深度测试。这意味着,当使用透明度混合渲染一个片元时,还是会比较它的深度值与当前深度缓冲中的深度值,如果它的深度值距离摄像机更远,那么就不会再进行混合操作。这一点决定了,当一个不透明物体出现在一个透明物体的前面,而我们先渲染了不透明物体,它仍然可以正常地遮挡住透明物体。也就是说,对于透明度混合来说,深度缓冲是只读的。

对于透明度混合技术,需要关闭深度写入,此时我们就需要小心处理透明物体的渲染顺序。

那么,我们为什么要关闭深度写入呢?如果不关闭深度写入,一个半透明表面背后的表面本来是可以透过它被我们看到的,但由于深度测试时判断结果是该半透明表面距离摄像机更近,导致后面的表面将会被剔除,我们也就无法透过半透明表面看到后面的物体了。但是,我们由此就破坏了深度缓冲的工作机制,关闭深度写入导致渲染顺序将变得非常重要。

7.1 - Unity Shader的渲染顺序

Unity为了解决渲染顺序提供了渲染队列,SubShaderQueue标签来决定我们的模型归于哪个渲染队列,索引号越小表示越早被渲染

名称 队列索引号 描述
Background 1000 这个渲染队列会在任何其他队列之前被渲染,我们通常使用该队列来渲染那些需要绘制在背景上的物体
Geometry 2000 默认的渲染队列,大多数物体都使用这个队列。不透明物体使用这个队列
AlphaTest 2450 需要透明度测试的物体使用这个队列。在 Unity 5中它从 Geometry 队列中被单独分出来,这是因为在所有不透明物体渲染之后再渲染它们会更加高效
Transparent 3000 这个队列中的物体会在所有 Geometry和 AlphaTest物体渲染后,再按从后往前的顺序进行渲染。任何使用了透明度混合(例如关闭了深度写入的 Shader)的物体都应该使用该队列
Overlay 4000 该队列用于实现一些叠加效果。任何需要在最后渲染的物体都应该使用该队列

因此,如果想通过透明度测试实现透明效果,代码中应包含以下:

1
2
3
4
5
6
7
SubShader
{
Tags { "Queue"="AlphaTest" }
Pass {
...
}
}

如果想通过透明度混合测试实现透明效果,代码中应包含以下:

1
2
3
4
5
6
7
8
SubShader
{
Tags { "Queue"="Transparent" }
Pass {
Zwrite off
...
}
}

其中,ZWrite Off用于关闭深度写入,在这里我们选择把它写在Pass中。我们也可以把它写在 SubShader 中,这意味着该SubShader下的所有Pass都会关闭深度写入。

7.3 - 透明度测试

透明度测试:只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响;否则,就会按照普通的不透明物体的处理方式来处理它。

通常,我们会在片元着色器中使用clip函数来进行透明度测试。clip是CG 中的一个函数,如果给定参数的任何一个分量是负数,就会舍弃当前像素的输出颜色。

Shader:Chapter8-AlphaTest

实现如下:

  1. 定义属性,声明一个范围在[0,1]的属性_Cutoff用来控制是否透明

  2. 定义Tag

    1
    2
    3
    4
    5
    6
    7
    SubShader
    {
    Tags {
    "Queue"="AlphaTest"
    "IgnoreProjector"="True"
    "RenderType"="TransparentCutout"
    }
    • 透明度测试使用的渲染队列是名为AlphaTest的队列,因此我们需要把Queue标签设置为AlphaTest
    • 而 RenderType标签可以让Unity把这个Shader归入到提前定义的组(这里就是TransparentCutout组)中,以指明该Shader是一个使用了透明度测试的 Shader。RenderType标签通常被用于着色器替换功能。
    • IgnoreProjector设置为True,这意味着这个 Shader 不会受到投影器(Projectors)的影响。
  3. 包含内置文件、声明变量、定义顶点着色器以及它的输入和输出结构体

  4. 修改片元着色器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
     fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 WorldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

    fixed4 texColor = tex2D(_MainTex, i.uv);

    clip(texColor.a - _Cutoff);

    fixed3 albedo = texColor.rgb * _Color.rgb;
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, WorldLightDir));


    return fixed4(ambient + diffuse, 1.0);
    }

    使用clip函数判断参数texColor.a是否小于材质参数_Cutoff,是就舍弃该片元的输出。

  5. 选择合适的FallBack

    1
    FallBack "Transparent/Cutout/VertexLit"

7.4 - 透明度混合

为了进行混合,我们需要使用Unity提供的混合命令:Blend,以下是ShaderLab的Blend命令。

语义 描述
Blend Off 关闭混合
Blend SrcFactor DstFactor 开启混合,并设置混合因子。源颜色(该片元产生的颜色)会乘以SrcFactor,而目标颜色(已经存在于颜色缓存的颜色)会乘以 DstFactor,然后把两者相加后再存入颜色缓冲
Blend SrcFactor DstFactor, SrcFactorA DstFactorA 和上面几乎一样,只是使用不同的因子来混合透明通道
BlendOp BlendOperation 并非是把源颜色和目标颜色简单相加后混合,而是使用BlendOperation对它们进行其他操作

Shader:Chapter8-AlphaBlend

实现如下:

  1. 定义属性,声明一个范围在[0,1]的属性_AlphaScale用来控制透明度

  2. 定义Tag

    1
    2
    3
    4
    5
    6
    7
    SubShader
    {
    Tags {
    "Queue"="Transparent"
    "IgnoreProjector"="True"
    "RenderType"="Transparent"
    }
    • 透明度混合使用的渲染队列是名为Transparent的队列,因此我们需要把Queue标签设置为Transparent
    • 而 RenderType标签可以让Unity把这个Shader归入到提前定义的组(这里就是Transparent组)中,以指明该Shader是一个使用了透明度混合的 Shader。RenderType标签通常被用于着色器替换功能。
    • IgnoreProjector设置为True,这意味着这个 Shader 不会受到投影器(Projectors)的影响。
  3. 与透明度测试不同的是,我们还需要在Pass中为透明的混合进行合适的混合状态设置

    1
    2
    3
    4
    5
    6
    Pass
    {
    Tags { "LightMode" = "ForwardBase" }

    Zwrite Off
    Blend SrcAlpha OneMinusSrcAlpha

    Pass的标签仍和之前一样,即把LightMode设为ForwardBase,这是为了让 Unity 能够按前向渲染路径的方式为我们正确提供各个光照变量。

    除此之外,我们还把该Pass 的深度写入(ZWrite)设置为关闭状态(Off)

    然后,我们开启并设置了该Pass 的混合模式。我们将源颜色(该片元着色器产生的颜色)的混合因子设为SrcAlpha,把目标颜色(已经存在于颜色缓冲中的颜色)的混合因子设为OneMinusSrcAlpha,以得到合适的半透明效果。

  4. 包含内置文件、声明变量、定义顶点着色器以及它的输入和输出结构体

  5. 修改片元着色器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 WorldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

    fixed4 texColor = tex2D(_MainTex, i.uv);


    fixed3 albedo = texColor.rgb * _Color.rgb;
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, WorldLightDir));


    return fixed4(ambient + diffuse, texColor.a * _AlphaScale);
    }

    唯一的更改的设置了该片元着色器返回值中的透明通道

  6. 选择合适的FallBack

    1
    Fallback "Transparent/vertexLit"

由于网格有复杂结构,这个透明效果仍不够好,仍然需要使用深入写入来优化

7.5 - 开启深度写入的半透明效果

Shader:Chapter8-AlphaBlendZWrite

实现如下:

使用Chapter8-AlphaBlend中的代码,在此基础上在原来的Pass前面添加一个新的Pass即可

1
2
3
4
5
Pass
{
Zwrite On
ColorMask 0
}
  • 这个新添加的Pass的目的仅仅是为了把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。因此,Pass的第一行开启了深度写入。
  • 在第二行,我们使用了一个新的渲染命ColorMask。在ShaderLab 中,ColorMask 用于设置颜色通道的写掩码(write mask)。当ColorMask设为0时,意味着该Pass 不写入任何颜色通道,即不会输出任何颜色。这正是我们需要的——该Pass只需写入深度缓存即可。

7.6 - ShaderLab的混合指令

7.6.1 - 混合等式和参数

混合是一个逐片元的操作,而且它不是可编程的,但却是高度可配置的。也就是说,我们可以设置混合时使用的运算操作、混合因子等来影响混合。

命令 描述
Blend SrcFactor DstFactor 开启混合,并设置混合因子。源颜色(该片元产生的颜色)会乘以SrcFactor,而目标颜色(已经存在于颜色缓存的颜色)会乘以DstFactor,然后把两者相加后再存入颜色缓冲中
Blend SrcFactor DstFactor, SrcFactor A DstFactorA 和上面几乎一样,只是使用不同的因子来混合透明通道

7.6.2 - 常见的混合类型

通过混合操作和混合因子命令的组合,我们可以得到一些类似 Photoshop混合模式中的混合效果:

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
// Normal 正常,即透明度混合
Blend SrcAlpha OneMinusSrcAlpha

// Soft Additive 柔和相加
Blend OneMinusDstColor One

// Multiply 正片叠底
Blend DstColor Zero

// 2x Multiply 两倍相乘
Blend DstColor SrcColor

// Darken 变暗
BlendOp Min
Blend One One // When using Min operation, these factors are ignored

// Lighten 变亮
BlendOp Max
Blend One One // When using Max operation, these factors are ignored

// Screen 滤色
Blend OneMinusDstColor One
//等同于
Blend One OneMinusSrcColor

// Linear Dodge 线性减淡
Blend One One

7.7 - 双面渲染的透明效果

7.7.1 - 透明度测试的双面渲染

这节的代码和透明度测试的代码几乎一样,只是在Pass语义块中加了一行:

1
2
3
4
5
Pass
{
Tags { "LightMode" = "ForwardBase" }

Cull off

7.7.2 - 透明度混合的双面渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SubShader
{
Tags {
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
}
Pass
{
Tags { "LightMode" = "ForwardBase" }
Cull Front
...
}

Pass
{
Tags { "LightMode" = "ForwardBase" }
Cull Back
...

为此,我们选择把双面渲染的工作分成两个Pass-第一Pass只渲染背面,第二个Pass只渲染正面,由于Unity会顺序执行SubShader中的各个Pass,因此我们可以保证背面总是在正面被渲染之前渲染,从而可以保证正确的深度渲染关系。

8 - 复杂光照

8.1 - Unity的渲染路径

在Unity中,渲染路径(Render Path)决定了光照是如何应用到Unity Shader中的。如果要和光源打交道,那么我们需要为每个Pass指定它使用的渲染路径。

LightMode标签支持的渲染路径选项:

标签名 描述
Always 不管使用哪种渲染路径,该Pass总是会被渲染,但不会计算任何光照
ForwardBase 用于前向渲染。该Pass 会计算环境光、最重要的平行光、逐顶点/SH光源和 Lightmaps
ForwardAdd 用于前向渲染。该Pass会计算额外的逐像素光源,每个Pass对应一个光源
Deferred 用于延迟渲染。该Pass会渲染G缓冲(G-buffer)
ShadowCaster 把物体的深度信息渲染到阴影映射纹理(shadowmap)或一张深度纹理中

8.1.1 - 向前渲染路径

向前渲染路径的原理:

每进行一次完整的向前渲染,我们需要渲染该对象的图元,并计算两个缓冲区的信息,一个是颜色缓冲区,一个是深度缓冲区。我们利用深度缓冲来决定一个片元是否可见,如果可见就更新颜色缓冲区中的颜色值。

对于每个逐像素光源,我们都需要进行上面一次完整的渲染流程。如果一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个 Pass,每个 Pass计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。假设,场景中有N个物体,每个物体受M个光源的影响,那么要渲染整个场景一共需要N*M个 Pass。可以看出,如果有大量逐像素光照,那么需要执行的Pass数目也会很大。因此,渲染引擎通常会限制每个物体的逐像素光照的数目。

Unity中的向前渲染:

一共Pass不仅仅可以用来计算逐像素光照,它也可以用来计算逐顶点等其他光照。

在Unity中,向前渲染路径有3种处理光照的方式,逐顶点处理逐像素处理球谐函数处理(Spherical Harmonics,SH)

光源模式指的是该光源是否是重要的(Important),如果一个光源被设置成重要的,那么它就会被逐像素处理。

Unity会根据场景中各个光源的设置以及这些光源对物体的影响程度(例如距离物体的远近、光源强度等),对这些光源进行一个重要度的排序。其中,一定数目的光源会按逐像素的方式处理,然后最多有4个光源按逐顶点的方式处理,剩下的光源可以按 SH方式处理。Unity 使用的判断规则如下。

  • 场景中最亮的平行光总是按逐像素处理的。
  • 渲染模式被设置成 Not Important的光源,会按逐顶点或者SH处理。
  • 渲染模式被设置成Important的光源,会按逐像素处理。
  • 如果根据以上规则得到的逐像素光源数量小于Quality Setting 中的逐像素光源数量(PixelLight Count),会有更多的光源以逐像素的方式进行渲染。

Unity的光照在Pass中进行计算,向前渲染有两种Pass:Base PassAdditional Pass

image-20230706104114037

对于前向渲染来说,一个Unity Shader通常会定义一个 Base Pass(Base Pass也可以定义多次,例如需要双面渲染等情况)以及Additional Pass。一个 Base Pass仅会执行一次(定义了多个 Base Pass的情况除外),
而一个Additional Pass会根据影响该物体的其他逐像素光源的数目被多次调用,即每个逐像素光源会执行一次Additional Pass。

向前渲染路径中可以使用的内置光照变量:

名称 类型 描述
_LightColor0 float4 该Pass处理的逐像素光源的颜色
_WorldSpaceLightPos0 float4 _WorldSpaceLightPos0.xyz是该 Pass处理的逐像素光源的位置。如果该光源是平行光,那么_WorldSpaceLightPos0.w是0,其他光源类型w值为1
_LightMatrix0 float4×4 从世界空间到光源空间的变换矩阵。可以用于采样cookie和光强衰减(attenuation)纹理
unity_4LightPosX0, unity_4LightPosY0,unity_4LightPosZ0 float4 仅用于 Base Pass。前4个非重要的点光源在世界空间中的位置
unity_4LightAtten0 float4 仅用于Base Pass。存储了前4个非重要的点光源的衰减因子
unity_LightColor half4[4] 仅用于Base Pass。存储了前4个非重要的点光源的颜色

向前渲染中可以使用的内置光照函数:

函数名 描述
float3 WorldSpaceLightDir(float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回世界空间中从该点到光源的光照方向。内部实现使用了Unity WorldSpaceLightDir函数。没有被归一化
float3 UnityWorldSpaceLightDir(float4 v) 仅可用于前向渲染中。输入一个世界空间中的顶点位置,返回世界空间中从该点到光源的光照方向。没有被归一化
float3 ObjSpaceLightDir(float4 v) 仅可用于前向渲染中。输入一个模型空间中的顶点位置,返回模型空间中从该点到光源的光照方向。没有被归一化
float3 Shade4PointLights (…) 仅可用于前向渲染中。计算四个点光源的光照,它的参数是已经打包进矢量的光照数据,通常就是表9.2中的内置变量,如 unity_4LightPosX0,unity_4LightPosY0,unity_ 4LightPosZ0、unity LightColor和 unity_4LightAtten0等。前向渲染通常会使用这个函数来计算逐顶点光照

8.1.2 - 顶点渲染路径

顶点照明渲染路径是对硬件配置要求最少、运算性能最高,但同时也是得到的效果最差的一种类型,它不支持那些逐像素才能得到的效果,例如阴影法线映射高精度的高光反射等。实际上,它仅仅是前向渲染路径的一个子集,也就是说,所有可以在顶点照明渲染路径中实现的功能都可以在前向渲染路径中完成。就如它的名字一样,顶点照明渲染路径只是使用了逐顶点的方式来计算光照,并没有什么神奇的地方。实际上,我们在上面的前向渲染路径中也可以计算一些逐顶点的光源。但如果选择使用顶点照明渲染路径,那么Unity 会只填充那些逐顶点相关的光源变量,意味着我们不可以使用一些逐像素光照变量。

顶点照明渲染路径通常在一个 Pass中就可以完成对物体的渲染。在这个Pass中,我们会计算我们关心的所有光源对该物体的照明,并且这个计算是按逐顶点处理的。这是Unity 中最快速的渲染路径,并且具有最广泛的硬件支持。

8.1.3 - 延迟渲染路径

前向渲染的问题是:当场景中包含大量实时光源时,前向渲染的性能会急速下降。例如,如果我们在场景的某一块区域放置了多个光源,这些光源影响的区域互相重叠,那么为了得到最终的光照效果,我们就需要为该区域内的每个物体执行多个 Pass来计算不同光源对该物体的光照结果,然后在颜色缓存中把这些结果混合起来得到最终的光照。然而,每执行一个 Pass我们都需要重新渲染一遍物体,但很多计算实际上是重复的。

延迟渲染主要包含了两个Pass 。在第一个Pass中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G缓冲区中。然后,在第二个 Pass中,我们利用G缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。

对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按逐像素的方式处理。但是,延迟渲染也有一些缺点。

  • 不支持真正的抗锯齿(anti-aliasing)功能。
  • 不能处理半透明物体。
  • 对显卡有一定要求。如果要使用延迟渲染的话,显卡必须支持 MRT (Multiple RenderTargets)、Shader Mode 3.0及以上、深度渲染纹理以及双面的模板缓冲。

当使用延迟渲染时,Unity要求我们提供两个Pass。

(1)第一个 Pass用于渲染G缓冲。在这个 Pass中,我们会把物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体来说,这个Pass仅会执行一次。

(2)第二个Pass用于计算真正的光照模型。这个 Pass 会使用上一个Pass中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。

8.2 - Unity的光源类型

Unity一共支持四种光源类型:平行光、点光源、聚光灯和面光源(Area Light),面光源仅在烘焙时才可发挥作用。

8.2.1 - 在向前渲染中处理不同的光源类型

Shader:Chapter9-ForwardRendering

  1. 使用Blinn-Phone光照模型,并为向前渲染定义了Bass PassAdditional Pass来处理多个光源。

  2. 定义第一个Pass - Base Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Pass
    {
    // Base Shader
    Tags { "LightMode" = "ForwardBase" }

    CGPROGRAM

    // Apparently need to add this declaration
    #pragma multi_compile_fwdbase

    除了设置渲染路径以外,还是用了#pragma编译指令。 #pragma multi_compile_fwdbase可以保证我们在Shader中使用的光照衰减等光照变量可以被正确赋值。

  3. 在Base Pass中计算环境光,我们希望环境光只计算一次,因此在之后的Additional Pass就不会在计算这个部分。与之类似的还有自发光。

    1
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
  4. 然后,我们在Base Pass 中处理了场景中的最重要的平行光。我们可以使用**_WorldSpaceLightPos0来得到这个平行光的方向(位置对平行光来说没有意义),使用_LightColor0来得到它的颜色和强度(_LightColor0已经是颜色和强度相乘后的结果),由于平行光可以认为是没有衰减的,因此这里我们直接令衰减值为1.0**。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fixed3 diffuse =  _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal,worldLight));

    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(worldNormal, halfDir)),_Gloss);

    fixed atten = 1.0;
    fixed3 color = ambient + (diffuse + specular) * atten;


    return fixed4(color, 1.0);
    • 如果场景中包含了多个平行光,Unity 会选择最亮的平行光传递给Base Pass-进行逐像素处理,其他平行光会按照逐顶点或在Additional Pass中按逐像素的方式处理。
    • 如果场景中没有任何平行光,那么 Base Pass 会当成全黑的光源处理。每一个光源有5个属性:位置、方向、颜色、强度以及衰减。对于Base Pass来说,它处理的逐像素光源类型一定是平行光。
  5. 接下来,需要对场景其他像素定义Additional Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass
    {
    // Additional Shader
    Tags { "LightMode" = "ForwardAdd" }

    Blend One One

    CGPROGRAM
    // Apparently need to add this declaration
    #pragma multi_compile_fwdadd

    除了设置渲染路径标签外,我们同样使用了#pragma multi_compile_fwdadd指令,如前面所
    说,这个指令可以保证我们在Additional Pass 中访问到正确的光照变量。与Base Pass不同的是,我们还使用 Blend 命令开启和设置了混合模式。这是因为,我们希望 Additional Pass计算得到的光照结果可以在帧缓存中与之前的光照结果进行叠加。如果没有使用Blend命令的话,AdditionalPass 会直接覆盖掉之前的光照结果。在本例中,我们选择的混合系数是Blend One One,这不是必需的,我们可以设置成 Unity支持的任何混合系数。常见的还有Blend SrcAlpha One

  6. 通常来说,Additional Pass中的光照处理和Base Pass中的处理方式是一样的,只需要去掉Base Pass中的环境光自发光逐顶点光照SH光照的部分,并添加对不同光源的支持

    1
    2
    3
    4
    5
    #ifdef USING_DIRECTIONAL_LIGHT
    fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
    #else
    fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
    #endif

    在上面的代码中,我们首先判断了当前处理的逐像素光源的类型,这是通过使用#ifdef指令判断是否定义了USING_DIRECTIONAL_LIGHT来得到的。如果当前前向渲染 Pass 处理的光源类型是平行光,那么Unity的底层渲染引擎就会定义USING DIRECTIONAL LIGHT。

    • 如果判断得知是平行光的话,光源方向可以直接由 WorldSpaceLightPos0.xyz得到;
    • 如果是点光源或聚光灯,那么 WorldSpaceLightPos0.xyz表示的是世界空间下的光源位置,而想要得到光源方向的话,我们就需要用这个位置减去世界空间下的顶点位置。
  7. 最后,我们需要处理不同光源的衰减:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #ifdef USING_DIRECTIONAL_LIGHT
    fixed atten = 1.0;
    #else
    #if defined(POINT)
    float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
    fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #elif defined(SPOT)
    float4 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1));
    fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #else
    fixed atten = 1.0;
    #endif
    #endif

    我们同样通过判断是否定义了USING_DIRECTIONAL_LIGHT来决定当前处理的光源类型。

    • 如果是平行光的话,衰减值为1.0。
    • 如果是其他光源类型,那么处理更复杂一些。Unity选择了使用一张纹理作为查找表(Lookup Table,LUT),以在片元着色器中得到光源的衰减。我们首先得到光源空间下的坐标,然后使用该坐标对衰减纹理进行采样得到衰减值。

当我们创建一个光源时,默认情况下它的 Render Mode(可以在 Light组件中设置)是Auto。这意味着,Unity 会在背后为我们判断哪些光源会按逐像素处理,而哪些按逐顶点或SH的方式处理。

由于我们没有更改Edit Project Settings - Quality →Pixel Light Count中的数值,因此默认情况下一个物体可以接收除最亮的平行光外的4个逐像素光照。

8.3 - Unity的光照衰减

Unity默认使用一张纹理:_LightTexture0 作为查找表来在片元着色器中计算逐像素光照的衰减

为了对**_LightTexture0**纹理采样得到给定点到该光源的衰减值;首先我们要得到该点在光源空间中的位置

1
float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;

然后,我们可以使用这个坐标的模的平方对衰减纹理进行采样,得到衰减值

1
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;

可以发现,在上面的代码中,我们使用了光源空间中顶点距离的平方(通过dot函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这种方法可以避免开方操作。然后,我们使用宏UNITY_ATTEN_CHANNEL 来得到衰减纹理中衰减值所在的分量,以得到最终的衰减值。

8.4 - Unity的阴影

8.4.1 - 阴影是如何产生的

在实时渲染中,我们最常使用的是一种名为Shadow Map的技术,它的原理是:

首先会把摄像机放在于光源重合的位置,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。

在向前渲染路径中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算阴影映射纹理(Shadowmap),它本质上是一张深度图,记录了从该光源位置出发,能够看到的场景中距离它最近的表面位置(深度信息)。

由于我们只需要深度信息,因此Unity使用LightMode标签为ShadowCaster的Pass来更新阴影映射纹理

  • 如果我们想要一个物体接收来自其他物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,然后把采样结果和最后的光照结果相乘来产生阴影效果。

  • 如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。

    在Unity中,这个过程是通过为该物体执行LightModeShadowCaster的Pass来实现的,如果使用了屏幕空间的投影映射技术,Unity还会使用这个Pass产生一张摄像机的深度纹理。

8.4.2 - 不透明物体的阴影

1.让物体投射阴影

在 Unity中,我们可以选择是否让一个物体投射或接收阴影。这是通过设置Mesh Renderer组件中的Cast ShadowsReceive Shadows属性来实现的。

在光源组件中,将Strength设置为0.3,阴影类型为软阴影

在默认情况下,计算光源的阴影映射纹理会剔除物体的背面,但对于内置的平面来说他只有一个面,在本例中,由于它右侧的平面在光源空间下没有任何正面,因此不会添加到阴影映射纹理中。可以将Cast Shadows设置为Two Sided来允许对物体所有面都计算阴影信息。

最简单的方法是适合合适的FallBack,Unity会为我们选择LightModeShadowCaster的Pass。

可以在Untiy的内置着色器中找到他:builtin_shaders-2023.1.2f1 -> DefaultResourcesExtra -> Normal-VertexLit.shader

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
32
33
Pass {
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#pragma multi_compile_shadowcaster
#pragma multi_compile_instancing // allow instanced shadow pass for most of the shaders
#include "UnityCG.cginc"

struct v2f {
V2F_SHADOW_CASTER;
UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert( appdata_base v )
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}

float4 frag( v2f i ) : SV_Target
{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG

}

它的实际作用是将深度信息写入渲染目标中,这个Pass是可以在多个Unity Shader中通用的,因此可以直接使用FallBack回调。

2.让物体接受阴影

  1. Base Pass包含一个新的内置文件,这个文件包含我们计算阴影所用的宏

    1
    #include "AutoLight.cginc"
  2. 为顶点着色器输出结构体v2f添加一个内置宏SHADOW_COORDS

    1
    2
    3
    4
    5
    6
    7
    struct v2f
    {
    float4 vertex : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;
    SHADOW_COORDS(2)
    };
    • 这个宏的作用是声明一个用于阴影纹理采样的坐标
    • 它的参数需要的是下一个可用的插值寄存器的索引值,这里是2。
  3. 顶点着色器返回之前添加另一个内置宏TRANSFER_SHADOW

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    v2f vert (appdata v)
    {
    v2f o;
    ...

    // Pass shadow coordinates to pixel shader
    TRANSFER_SHADOW(o);

    return o;
    }

    这个宏用于计算在v2f中声明的阴影纹理坐标

  4. 接着,在片元着色器中计算阴影值,这里同样使用了一个内置宏SHADOW_ATTENUATION

    1
    fixed shadow = SHADOW_ATTENUATION(i);

    需要注意的是,这些宏会使用上下文变量来进行相关计算,例如TRANSFER_SHADOW会使用v.vertexa.pos来计算,因此为了能够让这些宏正确工作,我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。

    我们需要保证:a2f结构体中的顶点坐标变量名必须是vertex,顶点着色器的输出结构体v2f必须命名为v,且 v2f中的顶点位置变量必须命名为pos.

  5. 完成了上面的操作之后,我们只需要把阴影值shadow漫反射以及高光反射颜色相乘即可。

8.4.3 - 统一管理光照衰减和阴影

光照衰减阴影对物体最终的渲染结果的影响本质上是相同的,都是把光照衰减因子和阴影值及光照结果相乘得到最终的渲染结果。在Unity中,提供了一个方法来同时计算两个信息:UNITY_LIGHT_ATTENUATION

代码与Shadow类似,只有以下部分进行变更:

  • 在片元着色器中使用宏UNITY_LIGHT_ATTENUATION来计算光照衰减和阴影

    1
    2
    3
    4
    5
    6
    7
    fixed4 frag(v2f i) : SV_Target {

    // UNITY_LIGHT_ATTENUATION not only compute attenuation, but also shadow infos
    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    return fixed4((diffuse + specular) * atten, 1.0);
    }

由于我们使用了UNITY_LIGHT_ATTENUATION,我们的Base PassAdditional Pass的代码得以统一,不需要在Base Pass中单独处理阴影,也不需要在Additional Pass中判断光源的类型来处理光照衰减。

8.4.4 - 透明物体的阴影

对于大多数不透明物体来说,把 Fallback 设为VertexLit 就可以得到正确的阴影。但对于透明物体来说,我们就需要小心处理它的阴影。透明物体的实现通常会使用透明度测试或透明度混合,我们需要小心设置这些物体的FallBack,

Alpha Test

Shader:Chapter9-AlphaTestWithShadow

  • 沿用了之前的Chapter8-AlphaTest的代码,在此基础上添加AutoLight内置文件以及宏,详见8.4.2 - 不透明物体的阴影 - 让物体接受阴影

  • 并且更改回调函数为

    1
    FallBack "Transparent/Cutout/VertexLit"

    如果使用了VertexLit,由于这个Pass没有进行任何透明的测试的计算,会把整个物体的深度信息渲染到深度图和阴影映射纹理中,会产生错误的结果。

    由于一些面背对光源,没有进入阴影映射纹理的计算中,可以使Mesh Renderer中的Cast Shadows属性设置为Two Sided,强制计算所有面的深度信息。

Alpha Blend

在Unity中,所有内置的半透明Shader是不会产生任何阴影效果的,我们可以使用一些dirty trick来强制为半透明物体生成阴影:比如将他们的FallBack设置为VertexLit,Diffuse这些不透明物体使用的Shader。

8.5 - 标准Unity Shader

本书提供了两个这样标准的Unity Shader—BumpedDiffuseBumpedSpecular。这两个Unity Shader都包含了对法线纹理、多光源、光照衰减和阴影的相关处理,唯一不同的是,BumpedDifuse使用了Phong光照模型,而BumpedSpecular使用了Blinn-Phong光照模型

9 - 高级纹理

9.1 - 立方体纹理

在图形学中,立方体纹理(Cubemap)是环境映射(Environment Mapping)的一种实现方法。环境映射可以模拟物体周围的环境,而使用了环境映射的物体可以看起来像镀了层金属一样反射出周围的环境。

  • 使用立方体纹理的好处在于,它的实现简单快速,而且得到的效果也比较好。
  • 但它也有一些缺点,例如当场景中引入了新的物体、光源,或者物体发生移动时,我们就需要重新生成立方体纹理。

除此之外,立方体纹理也仅可以反射环境,但不能反射使用了该立方体纹理的物体本身。这是因为,立方体纹理不能模拟多次反射的结果,例如两个金属球互相反射的情况.想要得到令人信服的渲染结果,我们应该尽量对凸面体而不要对凹面体使用立方体纹理(因为凹面体会反射自身)。

常见的立方体纹理应用有以下几种:

  • 天空盒

  • 环境映射

    在 Unity 5中,创建用于环境映射的立方体纹理的方法有三种:

    第一种方法是直接由一些特殊布局的纹理创建;

    第二种方法是手动创建一个Cubemap资源,再把6张图赋给它;

    第三种方法是由脚本生成。

  • 反射

  • 折射

  • 菲涅尔反射

9.1.1 - 反射

Shader:Chapter10-Reflection

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _ReflectColor ("Reflect Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _ReflectAmount ("Reflect Amount", Range(0, 1)) = 1
    _Cubemap ("Cubemap", Cube) = "_Skybox" {}
    }

    其中,**_ReflectColor用于控制反射颜色,_ReflectAmount用于控制这个材质的反射程度,_Cubemap**就是用于模拟反射的环境映射纹理。

  2. 我们在顶点着色器中计算了该顶点处的反射方向,这是通过使用CG的reflect函数来实现的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.vertex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);

    o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);

    TRANSFER_SHADOW(o);

    return o;
    }

    物体反射到摄像机中的光线方向,可以由光路可逆的原则来反向求得。也就是说,我们可以计算视角方向关于顶点法线的反射方向来求得入射光线的方向。

  3. 在片元着色器中,可以利用反射方向来对立方体纹理采样

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    fixed3 worldViewDir = normalize(i.worldViewDir);

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));

    // Use the reflect dir in world space to access the cubemap
    fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflectColor.rgb;

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    // Mix the diffuse color with the reflected color
    fixed3 color = ambient + lerp(diffuse, reflection, _ReflectAmount) * atten;

    return fixed4(color, 1.0);
    }

    对立方体纹理的采样需要使用CG的texCUBE函数。注意,在上面的计算中,我们在采样时并没有对i.worldRefl进行归一化操作。这是因为,用于采样的参数仅仅是作为方向变量传递给texCUBE 函数的,因此我们没有必要进行一次归一化的操作。

    然后,我们使用ReflectAmount来混合漫反射颜色和反射颜色,并和环境光照相加后返回。

  4. 选择Cubemap到材质中。

9.1.2 - 折射

当给定入射角时,根据斯涅耳定律(Snell’s Law)可以计算反射角时,当光从介质1沿着和表面法线夹角为θ1的方向斜射入介质2时,我们可以使用如下公式计算折射光线与法线的夹角θ2:
$$
\eta_1 sin\theta_1 = \eta_2 sin\theta_2
$$

其中,η1和η2分别是两个介质的折射率(index of refraction)。折射率是一项重要的物理常数,例如真空的折射率是1,而玻璃的折射率一般是1.5。
通常来说,当得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但这是不符合物理规律的。对一个透明物体来说,一种更准确的模拟方法需要计算两次折射—一次是当光线进入它的内部时,而另一次则是从它内部射出时。但是,想要在实时渲染中模拟出第二次折射方向是比较复杂的,而且仅仅模拟一次得到的效果从视觉上看起来“也挺像那么回事的”。正如我们之前提到的——图形学第一准则“如果它看起来是对的,那么它就是对的”。因此,在实时渲染中我们通常仅模拟第一次折射。

Shader:Chapter10-Refraction

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    Properties
    {
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _RefractColor ("Refract Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _RefractAmount ("Refract Amount", Range(0, 1)) = 1
    _RefractRatio ("Refract Ratio" , Range(0.1 ,1)) = 0.5
    _Cubemap ("Cubemap", Cube) = "_Skybox" {}
    }

    其中,RefractColorRefractAmountCubemap 与反射一节中控制反射时使用的属性类似。
    除此之外,我们还使用了一个属性 RefractRatio,我们需要使用该属性得到不同介质的透射比,以此来计算折射方向。

  2. 我们在顶点着色器中计算了折射方向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    v2f vert(appdata v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.normal);

    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);

    // Compute the refract dir in world space
    o.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);

    TRANSFER_SHADOW(o);

    return o;
    }

    我们使用了CG的refract函数来计算折射方向。

    它的第一个参数即为入射光线的方向,它必须是归一化后的矢量;

    第二个参数是表面法线,法线方向同样需要是归一化后的;

    第三个参数是入射光线所在介质的折射率和折射光线所在介质的折射率之间的比值,例如如果光是从空气射到玻璃表面,那么这个参数应该是空气的折射率和玻璃的折射率之间的比值,即1/1.5。

    它的返回值就是计算而得的折射方向,它的模则等于入射光线的模。

  3. 在片元着色器中,可以利用折射方向来对立方体纹理采样

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    fixed4 frag(v2f i) : SV_Target {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    fixed3 worldViewDir = normalize(i.worldViewDir);

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));

    // Use the refract dir in world space to access the cubemap
    fixed3 refraction = texCUBE(_Cubemap, i.worldRefr).rgb * _RefractColor.rgb;

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    // Mix the diffuse color with the refract color
    fixed3 color = ambient + lerp(diffuse, refraction, _RefractAmount) * atten;

    return fixed4(color, 1.0);
    }

    同样,我们也没有对i.worldRefr进行归一化操作,因为对立方体纹理的采样只需要提供方向即可。最后,我们使用RefractAmount来混合漫反射颜色和折射颜色,并和环境光照相加后返回。

  4. 选择Cubemap到材质中。

9.1.3 - 菲涅尔反射

这是基于物理的渲染中非常重要的一项高光反射计算因子。通俗地讲,菲涅耳反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。被反射的光和入射光之间存在一定的比率关系,这个比率关系可以通过菲涅耳等式进行计算。

参考阅读:http://filmicworlds.com/blog/everything-has-fresnel/

那么,我们如何计算菲涅耳反射呢?这就需要使用菲涅耳等式。真实世界的菲涅耳等式是非常复杂的,但在实时渲染中,我们通常会使用
一些近似公式来计算。其中一个著名的近似公式就是 Schlick菲涅耳近似等式:
$$
F_{schlick}(v, n) = F_0 + (1 - F_0)(1 - v \cdot n)^5
$$
使用上面的菲涅耳近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质的渲染中,我们会经常使用菲涅耳反射来模拟更加真实的反射效果。

Shader:Chapter10-Refraction

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    Properties
    {
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _FresnelScale ("Fresnel Scale", Range(0, 1)) = 1
    _Cubemap ("Cubemap", Cube) = "_Skybox" {}
    }
  2. 我们在顶点着色器中计算了法线方向、视角方向以及反射方向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    o.worldNormal = UnityObjectToWorldNormal(v.vertex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);

    o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);

    TRANSFER_SHADOW(o);

    return o;
    }
  3. 在片元着色器中计算菲涅尔反射,并使用结果值混合漫反射光在和反射光照

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    fixed3 worldViewDir = normalize(i.worldViewDir);

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb;
    fixed fresnel = _FresnelScale + (1 - _FresnelScale) * pow(1 - dot(worldViewDir, worldNormal), 5);

    fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));

    fixed3 color = ambient + lerp(diffuse, reflection, saturate(fresnel)) * atten;

    return fixed4(color, 1.0);
    }

    在上面的代码中,我们使用Schlick 菲涅耳近似等式来计算fresnel变量,并使用它来混合漫反射光照和反射光照。一些实现也会直接把 fresnel和反射光照相乘后叠加到漫反射光照上,模拟边缘光照的效果。

    当我们把**_FresnelScale调节到1时,物体将完全反射Cubemap中的图像;当_FresnelScale0**时,则是一个具有边缘光照效果的漫反射物体。

  4. 选择Cubemap到材质中。

9.2 - 渲染纹理

9.2.1 - 镜子效果

Shader:Chapter10-Mirror

  1. 准备好场景,创建一个Render Texture,以及准备一个用于观察场景的摄像机。

  2. 在顶点着色器中翻转uv的x,因为镜子中显示的图像是左右相反的。

    1
    2
    3
    4
    5
    6
    7
    8
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.uv.x = 1 - o.uv.x;
    return o;
    }
  3. 在片元着色器中对渲染纹理进行采用,将纹理拖入材质中。

9.2.2 - 玻璃效果

Shader:Chapter10-GlassRefraction

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties {
    _MainTex ("Main Tex", 2D) = "white" {}
    _BumpMap ("Normal Map", 2D) = "bump" {}
    _Cubemap ("Environment Cubemap", Cube) = "_Skybox" {}
    _Distortion ("Distortion", Range(0, 100)) = 10
    _RefractAmount ("Refract Amount", Range(0.0, 1.0)) = 1.0
    }

    其中,**_MainTex**是该玻璃的材质纹理,默认为白色纹理;

    _BumpMap是玻璃的法线纹理;
    _Cubemap是用于模拟反射的环境纹理;

    _Distortion则用于控制模拟折射时图像的扭曲程度;
    _RefractAmount用于控制折射程度,当**_RefractAmount值为0时,该玻璃只包含反射效果当_RefractAmount**值为1时,该玻璃只包括折射效果。

  2. 定义相应的渲染队列,并使用GrabPass来获取屏幕图像

    1
    2
    3
    4
    5
    6
    // We must be transparent, so other objects are drawn before this one.
    Tags { "Queue"="Transparent" "RenderType"="Opaque" }

    // This pass grabs the screen behind the object into a texture.
    // We can access the result in the next pass as _RefractionTex
    GrabPass { "_RefractionTex" }
    • 我们首先在SubShader的标签中将渲染队列设置成Transparent,尽管在后面的RenderType被设置为了 Opaque。这两者看似矛盾,但实际上服务于不同的需求。我们在之前说过,把Queue设置成Transparent可以确保该物体渲染时,其他所有不透明物体都已经被渲染到屏幕上了否则就可能无法正确得到“透过玻璃看到的图像”。而设置 RenderType 则是为了在使用着色器替换
      (Shader Replacement)时,该物体可以在需要时被正确渲染。这通常发生在我们需要得到摄像机的深度和法线纹理时。
    • 随后,我们通过关键词 GrabPass定义了个抓取屏幕图像的 Pass。在这个 Pass中我们定义了一个字符串,该字符串内部的名称决定了抓取得到的屏幕图像将会被存入哪个纹理中。实际上,我们可以省略声明该字符串,但直接声明纹理名称的方法往往可以得到更高的性能。
  3. 定义变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    sampler2D _MainTex;
    float4 _MainTex_ST;
    sampler2D _BumpMap;
    float4 _BumpMap_ST;
    samplerCUBE _Cubemap;
    float _Distortion;
    fixed _RefractAmount;
    sampler2D _RefractionTex;
    float4 _RefractionTex_TexelSize;

    需要注意的是,我们还定义了**_RefractionTex_RefractionTex_TexelSize变量,这对应了在使用GrabPass时指定的纹理名称。RefractionTex_ TexelSize**可以让我们得到该纹理的纹素大小,例如一个大小为256×512的纹理,它的纹素大小为(1/256,1/512)。我们需要在对屏幕图像的采样坐标进行偏移时使用该变量。

  4. 定义顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    v2f vert (a2v v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    o.scrPos = ComputeGrabScreenPos(o.pos);

    o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);

    float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
    fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
    fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;

    o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
    o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
    o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);

    return o;
    }

    在进行了必要的顶点坐标变换后,我们通过调用内置的ComputeGrabScreenPos函数来得到对应被抓取的屏幕图像的采样坐标。可以在UnityCG.cginc文件中找到它的声明,它的主要代码和ComputeScreenPos基本类似,最大的不同是针对平台差异造成的采样坐标问题进行了处理。

    接着,我们计算了MainTexBumpMap的采样坐标,并把它们分别存储在一一个float4类型变量的xyzw分量中。由于我们需要在片元着色器中把法线方向从切线空间(由法线纹理采样得到)变换到世界空间下,以便对Cubemap进行采样,因此,我们需要在这里计算该顶点对应的从切线空间到世界空间的变换矩阵,并把该矩阵的每一行分别存储在TtoW0、TtoW1和TtoW2的xyz分量中。这里面使用的数学方法就是,得到切线空间下的3个坐标轴(xyz 轴分别对应了副切线、切线和法线的方向)在世界空间下的表示,再把它们依次按列组成-一个变换矩阵即可。TtoW0 等值的w轴同样被利用起来,用于存储世界空间下的顶点坐标。

  5. 定义片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    fixed4 frag (v2f i) : SV_Target {		
    float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
    fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));

    // Get the normal in tangent space
    fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));

    // Compute the offset in tangent space
    float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
    i.scrPos.xy = offset * i.scrPos.z + i.scrPos.xy;
    fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb;

    // Convert the normal to world space
    bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
    fixed3 reflDir = reflect(-worldViewDir, bump);
    fixed4 texColor = tex2D(_MainTex, i.uv.xy);
    fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb * texColor.rgb;

    fixed3 finalColor = reflCol * (1 - _RefractAmount) + refrCol * _RefractAmount;

    return fixed4(finalColor, 1);
    }

    我们首先通过TtoW0等变量的w分量得到世界坐标,并用该值得到该片元对应的视角方向。
    随后,我们对法线纹理进行采样,得到切线空间下的法线方向。我们使用该值和 Distortion属性以及**_RefractionTex_TexelSize来对屏幕图像的采样坐标进行偏移,模拟折射效果。_Distortion值越大,偏移量越大玻璃背后的物体看起来变形程度越大**。在这里,我们选择使用切线空间下的法线方向来进行偏移,是因为该空间下的法线可以反映顶点局部空间下的法线方向。

    随后,我们对scrPos透视除法得到真正的屏幕坐标,再使用该坐标对抓取的屏幕图像**_RefractionTex进行采样,得到模拟的折射颜色。之后,我们把法线方向从切线空间变换到了世界空间下(使用变换矩阵的每一行,即TtoWo、TtoW1 和TtoW2,分别和法线方向点乘,构成新的法线方向),并据此得到视角方向相对于法线方向的反射方向。随后,使用反射方向对 Cubemap进行采样,并把结果和主纹理颜色相乘后得到反射颜色。
    最后,我们使用
    RefractAmount属性对反射和折射颜色进行混合,作为最终的输出颜完成后,我们把本书资源中的Glass Diffuse.jpg和 Glass Normal.jpg文件赋给材质的Main Tex和 Normal Map属性,把之前创建的 Glass Cubemap赋给 Environment Cubemap属性,再调整RefractAmount**属性即可得到类似图10.13 中的玻璃效果。

  6. 选择MainTex、Normal map、Cubemap到材质中。

9.2.3 - 渲染纹理 VS. GrabPass

Grap Pass和渲染纹理都可以抓取屏幕图像,但是他们之间有一些不同:

  • GrabPass实现简单,渲染纹理需要额外创建渲染纹理以及摄像机
  • 效率上渲染纹理好于GrabPass。

9.2.4 - 使用程序生成Cubemap

包含以下文件:

  • Editor->RenderCubemapWizard
  • Script->ProceduralTextureGeneration

需要以上文件才能在菜单栏的GameObject->Render into Cubemap中执行。

RenderCubemapWizard:

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
32
33
using UnityEngine;
using UnityEditor;
using System.Collections;

public class RenderCubemapWizard : ScriptableWizard {

public Transform renderFromPosition;
public Cubemap cubemap;

void OnWizardUpdate () {
helpString = "Select transform to render from and cubemap to render into";
isValid = (renderFromPosition != null) && (cubemap != null);
}

void OnWizardCreate () {
// create temporary camera for rendering
GameObject go = new GameObject( "CubemapCamera");
go.AddComponent<Camera>();
// place it on the object
go.transform.position = renderFromPosition.position;
// render into cubemap
go.GetComponent<Camera>().RenderToCubemap(cubemap);

// destroy temporary camera
DestroyImmediate( go );
}

[MenuItem("GameObject/Render into Cubemap")]
static void RenderCubemap () {
ScriptableWizard.DisplayWizard<RenderCubemapWizard>(
"Render cubemap", "Render!");
}
}

ProceduralTextureGeneration:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Serialization;

[ExecuteInEditMode]
public class ProceduralTextureGeneration : MonoBehaviour {

public Material material = null;

#region Material properties

[FormerlySerializedAs("textureWidth")]
[SerializeField]
private int m_textureWidth = 512;
public int textureWidth {
get {
return m_textureWidth;
}
set {
m_textureWidth = value;
_UpdateMaterial();
}
}

[FormerlySerializedAs("backgroundColor")]
[SerializeField]
private Color m_backgroundColor = Color.white;
public Color backgroundColor {
get {
return m_backgroundColor;
}
set {
m_backgroundColor = value;
_UpdateMaterial();
}
}

[FormerlySerializedAs("circleColor")]
[SerializeField]
private Color m_circleColor = Color.yellow;
public Color circleColor {
get {
return m_circleColor;
}
set {
m_circleColor = value;
_UpdateMaterial();
}
}


[FormerlySerializedAs("blurFactor")]
[SerializeField]
private float m_blurFactor = 2.0f;
public float blurFactor {
get {
return m_blurFactor;
}
set {
m_blurFactor = value;
_UpdateMaterial();
}
}
#endregion

private Texture2D m_generatedTexture = null;

// Use this for initialization
void Start () {
if (material == null) {
Renderer renderer = gameObject.GetComponent<Renderer>();
if (renderer == null) {
Debug.LogWarning("Cannot find a renderer.");
return;
}

material = renderer.sharedMaterial;
}

_UpdateMaterial();
}

private void _UpdateMaterial() {
if (material != null) {
m_generatedTexture = _GenerateProceduralTexture();
material.SetTexture("_MainTex", m_generatedTexture);
}
}

private Color _MixColor(Color color0, Color color1, float mixFactor) {
Color mixColor = Color.white;
mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor);
mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor);
mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor);
mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor);
return mixColor;
}

private Texture2D _GenerateProceduralTexture() {
Texture2D proceduralTexture = new Texture2D(textureWidth, textureWidth);

// The interval between circles
float circleInterval = textureWidth / 4.0f;
// The radius of circles
float radius = textureWidth / 10.0f;
// The blur factor
float edgeBlur = 1.0f / blurFactor;

for (int w = 0; w < textureWidth; w++) {
for (int h = 0; h < textureWidth; h++) {
// Initalize the pixel with background color
Color pixel = backgroundColor;

// Draw nine circles one by one
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
// Compute the center of current circle
Vector2 circleCenter = new Vector2(circleInterval * (i + 1), circleInterval * (j + 1));

// Compute the distance between the pixel and the center
float dist = Vector2.Distance(new Vector2(w, h), circleCenter) - radius;

// Blur the edge of the circle
Color color = _MixColor(circleColor, new Color(pixel.r, pixel.g, pixel.b, 0.0f), Mathf.SmoothStep(0f, 1.0f, dist * edgeBlur));

// Mix the current color with the previous color
pixel = _MixColor(pixel, color, color.a);
}
}

proceduralTexture.SetPixel(w, h, pixel);
}
}

proceduralTexture.Apply();

return proceduralTexture;
}
}

9.3 - 程序纹理

**程序纹理(Procedural Texture)**指的是那些由计算机生成的图像,我们通常使用一些特定的算法来创建个性化图案或非常真实的自然元素,例如木头、石子等。

使用程序纹理的好处在于我们可以使用各种参数来控制纹理的外观,而这些属性不仅仅是那些颜色属性,甚至可以是完全不同类型的图案属性,这使得我们可以得到更加丰富的动画和视觉效果。

9.3.1 - 在Unity中实现简单的程序纹理

SetProperty无法使用,该插件可以当我们修改了材质属性时,执行set中的_UpdateMaterial函数来使用新的属性重新生成程序纹理。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Serialization;

[ExecuteInEditMode]
public class ProceduralTextureGeneration : MonoBehaviour {

public Material material = null;

#region Material properties

[FormerlySerializedAs("textureWidth")]
[SerializeField]
private int m_textureWidth = 512;
public int textureWidth {
get {
return m_textureWidth;
}
set {
m_textureWidth = value;
_UpdateMaterial();
}
}

[FormerlySerializedAs("backgroundColor")]
[SerializeField]
private Color m_backgroundColor = Color.white;
public Color backgroundColor {
get {
return m_backgroundColor;
}
set {
m_backgroundColor = value;
_UpdateMaterial();
}
}

[FormerlySerializedAs("circleColor")]
[SerializeField]
private Color m_circleColor = Color.yellow;
public Color circleColor {
get {
return m_circleColor;
}
set {
m_circleColor = value;
_UpdateMaterial();
}
}


[FormerlySerializedAs("blurFactor")]
[SerializeField]
private float m_blurFactor = 2.0f;
public float blurFactor {
get {
return m_blurFactor;
}
set {
m_blurFactor = value;
_UpdateMaterial();
}
}
#endregion

private Texture2D m_generatedTexture = null;

// Use this for initialization
void Start () {
if (material == null) {
Renderer renderer = gameObject.GetComponent<Renderer>();
if (renderer == null) {
Debug.LogWarning("Cannot find a renderer.");
return;
}

material = renderer.sharedMaterial;
}

_UpdateMaterial();
}

private void _UpdateMaterial() {
if (material != null) {
m_generatedTexture = _GenerateProceduralTexture();
material.SetTexture("_MainTex", m_generatedTexture);
}
}

private Color _MixColor(Color color0, Color color1, float mixFactor) {
Color mixColor = Color.white;
mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor);
mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor);
mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor);
mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor);
return mixColor;
}

private Texture2D _GenerateProceduralTexture() {
Texture2D proceduralTexture = new Texture2D(textureWidth, textureWidth);

// The interval between circles
float circleInterval = textureWidth / 4.0f;
// The radius of circles
float radius = textureWidth / 10.0f;
// The blur factor
float edgeBlur = 1.0f / blurFactor;

for (int w = 0; w < textureWidth; w++) {
for (int h = 0; h < textureWidth; h++) {
// Initalize the pixel with background color
Color pixel = backgroundColor;

// Draw nine circles one by one
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
// Compute the center of current circle
Vector2 circleCenter = new Vector2(circleInterval * (i + 1), circleInterval * (j + 1));

// Compute the distance between the pixel and the center
float dist = Vector2.Distance(new Vector2(w, h), circleCenter) - radius;

// Blur the edge of the circle
Color color = _MixColor(circleColor, new Color(pixel.r, pixel.g, pixel.b, 0.0f), Mathf.SmoothStep(0f, 1.0f, dist * edgeBlur));

// Mix the current color with the previous color
pixel = _MixColor(pixel, color, color.a);
}
}

proceduralTexture.SetPixel(w, h, pixel);
}
}

proceduralTexture.Apply();

return proceduralTexture;
}
}

9.3.2 - Unity的程序材质

使用Substance Designer可以生成程序材质,这些材质都是以.sbsar为后缀的,导入Unity后就会生成一个程序纹理资源。

程序材质自由度很高,调整属性可以得到不同的材质效果。

10 - 画面动效

10.1 - Unity的内置时间变量

动画效果往往都是把时间添加到一些变量的计算中,以便在时间变化时画面也可以随之变化。

名称 类型 描述
_Time float4 t是自该场景加载开始所经过的时间,4个分量的值分别是(t/20, t, 2t, 3t)。
_SinTime float4 t是时间的正弦值,4个分量的值分别是(t/8, t/4, t/2,t)
_CosTime float4 t是时间的余弦值,4个分量的值分别是(t/8, t/4,t/2, t)
unity_DeltaTime float4 dt是时间增量,4个分量的值分别是(dt, 1/dt, smoothDt, 1/smoothDt)

10.2 - 纹理动画

10.2.1 - 序列帧动画

最常见的纹理动画之一就是序列帧动画。序列帧动画的原理非常简单,它像放电影一样,依次播放一系列关键帧图像,当播放速度达到一定数值时,看起来就是一个连续的动画。

它的优点在于灵活性很强,我们不需要进行任何物理计算就可以得到非常细腻的动画效果。

而它的缺点也很明显,由于序列帧中每张关键帧图像都不一样。因此,要制作一张出色的序列帧纹理所需要的美术工程量也比较大。

Shader:Chapter11-ImageSequenceAnimation

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    Properties
    {
    _Color ("Color", Color) = (1, 1, 1, 1)
    _MainTex ("Texture", 2D) = "white" {}
    _HorizontalAmount ("Horizontal Amount", Float) = 4
    _VerticalAmount ("Vertical Amount", Float) = 4
    _Speed ("Speed", Range(1, 100)) = 30
    }

    _MainTex就是包含了所有关键帧图像的纹理,**_HorizontalAmount_VerticalAmount分别代表水平和垂直方向上包含的关键帧图像个数。_Speed**用于控制序列帧动画播放的速度。

  2. 由于帧序列图像是透明纹理,我们需要设置Pass相关状态以渲染透明效果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    SubShader
    {
    Tags {
    "Queue"="Transparent"
    "IgnoreProjector"="True"
    "RenderType"="Transparent"
    }

    Pass
    {
    Tags { "LightMode" = "ForwardBase" }

    ZWrite off
    Blend SrcAlpha OneMinusSrcAlpha

    由于序列帧图像通常包含了透明通道,因此可以被当成是一个半透明对象。在这里我们使用半透明的“标配”来设置它的SubShader标签,即把 Queue和 RenderType设置成 Transparent,把IgnoreProjector 设置为True。在 Pass中,我们使用Blend命令来开启并设置混合模式,同时关闭了深度写入。

  3. 在顶点着色器只进行基本的顶点变换,并把纹理坐标存到v2f的结构体中。

  4. 在片元着色器中计算动画

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fixed4 frag (v2f i) : SV_Target
    {
    float time = floor(_Time.y * _Speed);
    float row = floor(time / _HorizontalAmount);
    float column = time - row * _VerticalAmount;

    // half2 uv = float2(i.uv.x /_HorizontalAmount, i.uv.y / _VerticalAmount);
    // uv.x += column / _HorizontalAmount;
    // uv.y -= row / _VerticalAmount;
    half2 uv = i.uv + half2(column, -row);
    uv.x /= _HorizontalAmount;
    uv.y /= _VerticalAmount;

    fixed4 col = tex2D(_MainTex, uv);
    col.rgb *= _Color;

    return col;
    }
    • 由上一节可知,_Time.y就是自场景加载后经过的时间,把_Time.y 乘以 _Speed可以得到模拟的时间。并使用floor函数对结果取整可以得到整数时间Time。

    • 使用time除以_HorizontalAmount,余数则是列索引。

    • 使用行列索引来取得真正的采样坐标。首先我们将原来的纹理坐标按行数和列数进行等分,然后我们使用row和column对结果进行偏移。

      需要注意的是,在竖直方向上进行等分需要用减法,这是因为Unity中的纹理坐标方向顺序和帧序列中的顺序是相反的。

    • 我们可以将上面注释的代码进行整合,得到以下的代码。

  5. 设置FallBack

    1
    FallBack "Transparent/vertexLit"

10.2.2 - 滚动的背景

本节的纹理资源来自OpenGameArt:http://opengameart.org

Shader:Chapter11-ScrollingBackground

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    Properties
    {
    _MainTex ("Base Texture", 2D) = "white" {}
    _DetailTex ("2nd Layer", 2D) = "white" {}
    _ScollX ("Base layer scroll speed", Float) = 1.0
    _Scoll2X ("2nd layer scroll speed", Float) = 1.0
    _Multiplier ("Layer Multiplier", Float) =1
    }

    _MainTex对应的是底层的背景,**_DetailTex对应的是顶层的背景,_ScollX_Scoll2X对应各自的水平移动速度,_Multiplier**用于控制整体的亮度。

  2. 在顶点着色器中实现动画

    1
    2
    3
    4
    5
    6
    7
    8
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv.xy = TRANSFORM_TEX(v.uv.xy, _MainTex) + frac(float2(_ScollX, 0.0) * _Time.y);
    o.uv.zw = TRANSFORM_TEX(v.uv.zw, _DetailTex) + frac(float2(_Scoll2X, 0.0) * _Time.y);
    return o;
    }

    frac函数通常用于执行周期性的操作,例如创建循环动画或实现周期性的颜色变化。它可以通过获取时间值的小数部分来控制动画或颜色的变化速度,以实现平滑循环的效果。

  3. 在片元着色器中采样背景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fixed4 frag (v2f i) : SV_Target
    {
    fixed4 firstLayer = tex2D(_MainTex, i.uv);
    fixed4 secondLayer = tex2D(_DetailTex, i.uv);

    fixed4 col = lerp(firstLayer, secondLayer, secondLayer.a);
    col.rgb *= _Multiplier;

    return col;
    }
  4. 设置FallBack

    1
    FallBack "VertexLit"

10.3 - 顶点动画

10.3.1 - 流动的河流

河流的模拟是顶点动画最常见的应用之一。它的原理通常就是使用正弦函数等来模拟水流的波动效果。

Shader:Chapter11-Water

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Properties
    {
    _MainTex ("Main Tex", 2D) = "white" {}
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _Magnitude ("Distortion Magnitude", Float) = 1
    _Frequency ("Distortion Frequency", Float) = 1
    _InvWaveLength ("Distortion Inverse Wave Length", Float) = 10
    _Speed ("Speed", Float) = 0.5
    }

    _MainTex是河流纹理,**_Color用于控制整体颜色,_Magnitude用于控制水流波动的幅度,_Frequency用于控制波动频率,_InvWaveLength用于控制波长的倒数(_InvWaveLength越大,波长越小)。_Speed**用于控制河流纹理的移动速度。

  2. 设置合适的标签

    1
    2
    3
    SubShader
    {
    Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}

    除了设置透明效果的标签之外,还禁止了批处理:DisableBatching。

    一些SubShader在使用Unity 的批处理功能时会出现问题,这时可以通过该标签来直接指明是否对该 SubShader 使用批处理。而这些需要特殊处理的Shader通常就是指包含了模型空间的顶点动画的Shader。这是因为,批处理会合并所有相关的模型,而这些模型各自的模型空间就会丢失。而在本例中,我们需要在物体的模型空间下对顶点位置进行偏移。因此,在这里需要取消对该Shader 的批处理操作。

  3. 设置Pass的渲染状态

    1
    2
    3
    4
    5
    6
    7
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha
    Cull Off

    这里关闭了深度写入,开启并设置了混合模式,并关闭了剔除功能。这是为了让水流的每个面都能显示。

  4. 在顶点着色器中进行了相关的顶点动画

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    v2f vert (appdata v)
    {
    v2f o;

    float4 offset;
    offset.yzw = float3(0.0, 0.0, 0.0);
    offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;

    o.vertex = UnityObjectToClipPos(v.vertex + offset);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.uv += float2(0.0, _Time.y * _Speed);

    return o;
    }

    我们首先计算顶点位移量。我们只希望对顶点的x方向进行位移,因此 yzw的位移量被设置为0。然后,我们利用**Frequency属性和内置的 Time.y变量来控制正弦函数的频率。为了让不同位置具有不同的位移,我们对上述结果加上了模型空间下的位置分量,并乘以_InvWaveLength**来控制波长。最后,我们对结果值乘以Magnitude属性来控制波动幅度,得到最终的位移。剩下的工作,我们只需要把位移量添加到顶点位置上,再进行正常的顶点变换即可。

  5. 片元着色器只需要对纹理进行采样以及颜色控制即可

    1
    2
    3
    4
    5
    6
    fixed4 frag (v2f i) : SV_Target
    {
    fixed4 col = tex2D(_MainTex, i.uv);
    col.rgb *= _Color.rgb;
    return col;
    }
  6. 最后设置合适的FallBack

    1
    FallBack "Transparent/VertexLit"

10.3.2 - 广告牌

另一种常见的顶点动画就是广告牌技术(Billboarding)。广告牌技术会根据视角方向来旋转一个被纹理着色的多边形(通常就是简单的四边形,这个多边形就是广告牌),使得多边形看起来好像总是面对着摄像机。广告牌技术被用于很多应用,比如渲染烟雾、云朵、闪光效果等。

Shader:Chapter11-Billborad

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _VerticalBillboarding ("Vertical Restrains", Range(0, 1)) = 1
    }

    _MainTex是广告牌显示的透明纹理,**_Color用于控制整体的颜色,_VerticalBillboarding用于调整是固定法线还是固定向上**的方向,约束垂直方向的程度。

  2. 设置合适的标签

    1
    2
    3
    SubShader
    {
    Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}

    除了设置透明效果的标签之外,还禁止了批处理:DisableBatching。

    一些SubShader在使用Unity 的批处理功能时会出现问题,这时可以通过该标签来直接指明是否对该 SubShader 使用批处理。而这些需要特殊处理的Shader通常就是指包含了模型空间的顶点动画的Shader。这是因为,批处理会合并所有相关的模型,而这些模型各自的模型空间就会丢失。而在本例中,我们需要在物体的模型空间下对顶点位置进行偏移。因此,在这里需要取消对该Shader 的批处理操作。

  3. 设置Pass的渲染状态

    1
    2
    3
    4
    5
    6
    7
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha
    Cull Off

    这里关闭了深度写入,开启并设置了混合模式,并关闭了剔除功能。这是为了让水流的每个面都能显示。

  4. 在顶点着色器中进行了相关的顶点动画

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    v2f vert (appdata v)
    {
    v2f o;

    float3 center = float3(0, 0, 0);
    float3 viewer = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1));

    float3 normalDir = viewer - center;
    normalDir.y = normalDir.y * _VerticalBillboarding;
    normalDir = normalize(normalDir);

    float3 upDir = abs(normalDir.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);
    float3 rightDir = normalize(cross(upDir, normalDir));
    upDir = normalize(cross(normalDir, rightDir));

    float3 centerOffs = v.vertex.xyz - center;
    float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir.z *centerOffs.z;

    o.vertex = UnityObjectToClipPos(float4(localPos,1));


    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return o;
    }
    1
    2
    float3 center = float3(0, 0, 0);
    float3 viewer = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1));
    1
    2
    3
    float3 normalDir = viewer - center;
    normalDir.y = normalDir.y * _VerticalBillboarding;
    normalDir = normalize(normalDir);
    1
    2
    3
    float3 upDir = abs(normalDir.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);
    float3 rightDir = normalize(cross(upDir, normalDir));
    upDir = normalize(cross(normalDir, rightDir));
    1
    2
    float3 centerOffs = v.vertex.xyz - center;
    float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir.z * centerOffs.z;
  5. 片元着色器只需要对纹理进行采样以及颜色控制即可

    1
    2
    3
    4
    5
    6
    fixed4 frag (v2f i) : SV_Target
    {
    fixed4 col = tex2D(_MainTex, i.uv);
    col.rgb *= _Color.rgb;
    return col;
    }
  6. 最后设置合适的FallBack

    1
    FallBack "Transparent/VertexLit"

11 - 屏幕后处理效果

屏幕后处理就是在渲染完整个场景得到屏幕图像后,在对这个图像进行一些列操作,实现各种屏幕特效。

11.1 - 建立一个基本的屏幕后处理脚本系统

实现屏幕后处理的基础在于得到渲染后的屏幕图像,Unity为我们提供了这样一个接口:OnRenderImage,它的声明如下:

1
MonoBehaviour.OnRenderImage (RenderTexture src, RenderTexture dest)

当我们再脚本中声明此函数后,Unity会把当前渲染得到的图像存储再第一个参数对应的源渲染纹理中,经过函数中的一系列操作后,再把目标渲染纹理(第二个参数对应的渲染纹理)显示到屏幕上

OnRenderImage函数中,我们通常利用Graphics.Blit来对渲染纹理进行处理。

1
2
3
public static void Blit (Texture src, RenderTexture dest);
public static void Blit (Texture src, RenderTexture dest, Material mat, int pass = -1);
public static void Blit (Texture src, Material mat,int pass =-1);

src对应原纹理,通常是当前屏幕的渲染纹理。

dest是目标纹理,如果它为null就会直接将结果显示在屏幕上。

mat是我们使用的材质。

pass的值默认为-1,表示将会以此调用Shader中所有的pass,否则只会调用给定索引的pass。

有时,我们希望在不透明的Pass(即渲染队列小于等于2500的 Pass,内置的Background、Geometry 和 AlphaTest渲染队列均在此范围内)执行完毕后立即调用OnRenderImage 函数,从而不对透明物体产生任何影响。此时,我们可以在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的。

要在 Unity 中实现屏幕后处理效果,过程通常如下:

  • 我们首先需要在摄像机中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现OnRenderImage函数来获取当前屏幕的渲染纹理。
  • 然后,再调用Graphics.Blit函数使用特定的 Unity Shader来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。
  • 对于一些复杂的屏幕特效,我们可能需要多次调用Graphics.Blit函数来对上一步的输出结果进行下一步处理。

在进行屏幕后处理之前,我们需要检查平台是否支持渲染纹理和屏幕特效、是否支持当前使用的Unity Shader等。我们创建一个用于屏幕后处理效果的基类:PostEffectsBase

包含一个重要的函数,用于基于指定的Shader创建材质:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material)
{
if (shader==null)
{
return null;
}

if (shader.isSupported && material && material.shader == shader)
{
return material;
}
else {
material = new Material(shader);
material.hideFlags = HideFlags.DontSave;
if (material)
return material;
else
return null;
}
}

11.2 - 调整屏幕的亮度、饱和度和对比度

Script:BrightnessSaturationAndContrast

  1. 继承基类:PostEffectsBase

  2. 声明效果需要的Shader,并由此创建相应的材质。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader briSatConShader;
    private Material briSatConMaterial;

    public Material material
    {
    get
    {
    briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
    return briSatConMaterial;
    }
    }
  3. 提供相应的调整连你高度、饱和度和对比度的参数:

    1
    2
    3
    4
    5
    6
    7
    8
    [Range(0.0f, 3.0f)]
    public float brightness = 1.0f;

    [Range(0.0f, 3.0f)]
    public float saturation = 1.0f;

    [Range(0.0f, 3.0f)]
    public float contrast = 1.0f;
  4. 定义OnRenderImage来进行特效处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void OnRenderImage(RenderTexture src, RenderTexture dest) {
    if (material != null) {
    material.SetFloat("_Brightness", brightness);
    material.SetFloat("_Saturation", saturation);
    material.SetFloat("_Contrast", contrast);

    Graphics.Blit(src, dest, material);
    } else {
    Graphics.Blit(src, dest);
    }
    }

    每当OnRenderImage函数被调用时,它会检查材质是否可用。如果可用,就把参数传递给材质,再调用 Graphics.Blit进行处理;否则,直接把原图像显示到屏幕上,不做任何处理。

Shader:Chapter12-BrightnessSaturationAndContrast

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _Brightness ("Brightness", Float) = 1
    _Saturation("Saturation", Float) = 1
    _Contrast("Contrast", Float) = 1
    }
  2. 渲染设置

    1
    2
    3
    4
    SubShader
    {
    // No culling or depth
    Cull Off ZWrite Off ZTest Always

    屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,为了防止它对其他物体产生影响,我们需要设置相关的渲染状态。在这里,我们关闭了深度写入,是为了防止它“挡住”在其后面被渲染的物体。

    例如,如果当前的OnRenderImage函数在所有不透明的Pass执行完毕后立即被调用,不关闭深度写入就会影响后面透明的Pass 的渲染。这些状态设置可以认为是用于屏幕后处理的Shader的“标配”。

  3. 声明变量

    1
    2
    3
    4
    sampler2D _MainTex;
    half _Brightness;
    half _Saturation;
    half _Contrast;

    这里关闭了深度写入,开启并设置了混合模式,并关闭了剔除功能。这是为了让水流的每个面都能显示。

  4. 顶点着色器只需要传递正确的纹理坐标

  5. 片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fixed4 frag (v2f i) : SV_Target
    {
    fixed4 renderTex = tex2D(_MainTex, i.uv);

    // Apply brightness
    fixed3 finalColor = renderTex.rgb * _Brightness;

    // Apply saturation
    fixed luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;
    fixed3 luminanceColor = fixed3(luminance, luminance, luminance);
    finalColor = lerp(luminanceColor, finalColor, _Saturation);

    // Apply contrast
    fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
    finalColor = lerp(avgColor, finalColor, _Contrast);

    return fixed4(finalColor, renderTex.a);
    }
    • 对于亮度,我们只需要将原来的颜色乘以亮度系数**_Brightness**
    • 对于饱和度,我们先计算该像素的亮度值luminance,这是有每一个颜色分量乘以一个特定系数相加得到的,我们使用该亮度值创建了一个饱和度为0的颜色值(灰度图),并使用**_Saturation**系数与上一步得到的颜色进行插值。
    • 对于对比度,我们先创建一个对比度为0的颜色值,即各分量均为0.5,在使用**_Contrast**系数与上一步得到的颜色值进行插值,从而得到最终的结果。
  6. 最后关闭FallBack

    1
    Fallback Off

11.3 - 边缘检测

11.3.1 - 卷积

图像处理中的卷积操作指的是使用一个卷积核对一张图像中每个像素进行一系列操作。

卷积核通常是一个四方形网格结构(例如2×2、3×3的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。

通过卷积可以实现许多常见的图像处理效果,例如图像模糊、边缘检测等。比如如果我们想对图像进行均值模糊,可以使用一个3x3的卷积核,核内每个元素的值均为1/9。

11.3.2 - 常见的边缘检测算子

卷积操作的神奇之处在于选择的卷积核。

为了选择合适的卷积核,我们首先要考虑边到底是如何形成的:如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界这种相邻像素之间的差值可以用梯度(gradientt)来表示,可以想象得到,边缘处的梯度绝对会比较大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。

image-20230711215947776

3种常见的边缘检测算子如图12.5所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值G_x和G_y,处于性能的考虑,我们会使用绝对值代替开根号的操作:
$$
G = \vert G_x \vert + \vert G_y\vert
$$
梯度越大,越有可能是边缘点。

11.3.3 - 实现

Script:EdgeDetection

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader EdgeDetectionShader = null;
    private Material EdgeDetectionMaterial = null;

    public Material material
    {
    get
    {
    EdgeDetectionMaterial = CheckShaderAndCreateMaterial(EdgeDetectionShader, EdgeDetectionMaterial);
    return EdgeDetectionMaterial;
    }
    }
  3. 在脚本中声明用于调整边缘线强度、描边颜色以及背景的参数

    1
    2
    3
    4
    [Range(0.0f, 1.0f)]
    public float edgeOnly = 0.0f;
    public Color edgeColor = Color.black;
    public Color backgroundColor = Color.white;

    edgeOnly为0时,边缘将直接叠加在图像上。edgeOnly为1时,只会显示边缘。

  4. 定义OnRenderImage来进行特效处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
    if (material != null)
    {
    material.SetFloat("_EdgeOnly", edgeOnly);
    material.SetColor("_EdgeColor", edgeColor);
    material.SetColor("_BackgroundColor", backgroundColor);

    Graphics.Blit(src, dest, material);
    }
    else
    {
    Graphics.Blit(src, dest);
    }
    }

Shader:Chapter12-EdgeDetection

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _EdgeOnly ("Edge Only", Float) = 1.0
    _EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
    _BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
    }
  2. 渲染设置

    1
    2
    3
    4
    SubShader
    {
    // No culling or depth
    Cull Off ZWrite Off ZTest Always
  3. 声明变量

    1
    2
    3
    4
    5
    sampler2D _MainTex;
    half4 _MainTex_TexelSize;
    fixed _EdgeOnly;
    fixed4 _EdgeColor;
    fixed4 _BackgroundColor;

    xxx_TexelSize是 Unity为我们提供的访问相应纹理对应的每个纹素的大小。例如,一张512×512大小的纹理,该值大约为0.001 953(即1/512)。由于卷积需要对相邻区域内的纹理进行采样,因此我们需要利用**_MainTex_TexelSize**来计算各个相邻区域的纹理坐标。

  4. 顶点着色器需要传递纹理坐标和它邻近的8个像素的坐标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    v2f vert (appdata v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    half2 uv = v.uv;

    o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
    o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
    o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
    o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
    o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
    o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
    o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
    o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
    o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
    return o;
    }

    我们在v2f结构体中定义了一个维数为9的纹理数组,对应了使用Sobel算子采样时需要的9个邻域纹理坐标。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。

    其中,o.uv[4]表示中间坐标,通过对这个坐标进行偏移得到周围的邻域纹理坐标。

  5. 片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fixed4 fragSobel(v2f i) : SV_Target
    {
    half edge = Sobel(i);

    fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
    fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);

    return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);

    }

    我们首先调用Sobel函数计算当前像素的梯度值edge,edge的值越小表示这个位置更可能是一个边缘点

    将这个值作为插值系数,可以分别控制带原先背景的图和空白背景的图。

    _EdgeOnly系数用于控制是否带背景。

    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
    fixed luminance(fixed4 color) {
    return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
    }

    half Sobel(v2f i)
    {
    const half Gx[9] = {-1, -2, -1,
    0, 0, 0,
    1, 2, 1};

    const half Gy[9] = {-1, 0, 1,
    -2, 0, 2,
    -1, 0, 1};

    half texColor;
    half edgeX = 0;
    half edgeY = 0;
    for(int it = 0; it<9; it++)
    {
    texColor = luminance(tex2D(_MainTex, i.uv[it]));
    edgeX += texColor * Gx[it];
    edgeY += texColor * Gy[it];
    }

    half edge = 1 - abs(edgeX) - abs(edgeY);
    return edge;
    }
    • 首先定义水平和竖直方向上使用的卷积核G_x和G_y。
    • 然后依次对输入的9个像素点进行采样,计算他们的亮度值(灰度图)。
    • 将灰度值像素与卷积核的权重相乘后,叠加到各自的梯度上。
    • 从1中减去水平方向和竖直方向上的梯度值的绝对值,得到edge,edge的值越小表示这个位置更可能是一个边缘点
  6. 最后关闭FallBack

    1
    Fallback Off

11.4 - 高斯模糊

11.4.1 - 高斯滤波

高斯模糊同样利用了卷积计算,它使用的卷积核为高斯核,其中每个元素的计算都是基于下面的高斯方程:
$$
G(x,y) = \frac{1}{2 \pi σ^2} e^{\frac{x^2+y^2}{2σ^2} }
$$

其中,σ是标准方差(一般取值为1),x和y分别对应了当前位置到卷积核中心的整数距离。高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度———距离越近,影响越大。高斯核的维数越高,模糊程度越大。

  • 要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值。
  • 为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1。因此,高斯函数中e前面的系数实际不会对结果有任何影响。

11.4.2 - 实现

在本节,我们将会使用上述5x5的高斯核对原图像进行高斯模糊。

我们将先后调用两个 Pass:

  • 第一个 Pass将会使用竖直方向的一维高斯核对图像进行滤波
  • 第二个 Pass再使用水平方向的维高斯核对图像进行滤波,得到最终的目标图像。
  • 在实现中,我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多,图像越模糊)。

高斯核的维数越高,模糊程度越大。使用一个NxN的高斯核对图像进行卷积滤波,就需要NXNxWxH(W和H分别是图像的宽和高)次纹理采样。当N的大小不断增加时,采样次数会变得非常巨大。幸运的是,我们可以把这个二维高斯函数拆分成两个一维函数。也就是说,我们可以使用两个一维的高斯核(图12.8中的右图)先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要2xN×W*H。我们可以进一步观察到,两个一维高斯核中包含了很多重复的权重。对于一个大小为5的一维高斯核,我们实际只需要记录3个权重值即可

image-20230712193951362

Script:GaussianBlur

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader GaussianBlursShader = null;
    private Material GaussianBlurMaterial = null;

    public Material material
    {
    get
    {
    GaussianBlurMaterial = CheckShaderAndCreateMaterial(GaussianBlursShader, GaussianBlurMaterial);
    return GaussianBlurMaterial;
    }
    }
  3. 在脚本中,我们还提供了调整高斯模糊迭代次数、模糊范围和缩放系数的参数:

    1
    2
    3
    4
    5
    6
    [Range(0f, 4f)]
    public int iterations = 3;
    [Range(0.2f, 3.0f)]
    public float blurSpread = 0.6f;
    [Range(1f, 8f)]
    public int downSample = 2;

    blurSpreaddownSample都是出于性能的考虑。

    在高斯核维数不变的情况下,BlurSize越大,模糊程度越高,但采样数却不会受到影响。但过大的**_BlurSize**值会造成虚影,这可能并不是我们希望的。而 downSample越大,需要处理的像素数越少,同时也能进一步提高模糊程度,但过大的downSample可能会使图像像素化,

  4. 定义OnRenderImage来进行特效处理

    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
    32
    33
    34
    35
    void OnRenderImage (RenderTexture src, RenderTexture dest) {
    if (material != null) {
    int rtW = src.width/downSample;
    int rtH = src.height/downSample;

    RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
    buffer0.filterMode = FilterMode.Bilinear;

    Graphics.Blit(src, buffer0);

    for (int i = 0; i < iterations; i++) {
    material.SetFloat("_BlurSize", 1.0f + i * blurSpread);

    RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

    // Render the vertical pass
    Graphics.Blit(buffer0, buffer1, material, 0);

    RenderTexture.ReleaseTemporary(buffer0);
    buffer0 = buffer1;
    buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

    // Render the horizontal pass
    Graphics.Blit(buffer0, buffer1, material, 1);

    RenderTexture.ReleaseTemporary(buffer0);
    buffer0 = buffer1;
    }

    Graphics.Blit(buffer0, dest);
    RenderTexture.ReleaseTemporary(buffer0);
    } else {
    Graphics.Blit(src, dest);
    }
    }
    • 我们首先利用缩放对图像进行降采样,并将该临时渲染纹理的滤波纹理模式设置为双线性,这样能够提高性能,适当的降采样还能得到更好的模糊效果。
    • 高斯模糊需要调用两个Pass:水平方向和垂直方向的Pass。
    • 使用blurSpread属性来控制**_BlurSize,在高斯核维度不变的情况下,_BlurSize**越大,模糊程度越高。
    • 在此基础上考虑了迭代次数,迭代次数越多图像越模糊。

Shader:Chapter12-GaussianBlur

  1. 声明以下属性:

    1
    2
    3
    4
    5
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _BlueSize("Blue Size", Float) = 1.0
    }
  2. 使用CGINCLUDE来组织代码,可以复用我们已经写过的相关函数。

    1
    2
    3
    4
    5
    SubShader
    {
    CGINCLUDE
    ...
    ENDCG
  3. 定义以下变量

    1
    2
    3
    sampler2D _MainTex;    
    half4 _MainTex_TexelSize;
    float _BlurSize;

    由于要得到相邻像素的纹理坐标,我们这里再一次使用了Unity 提供的_MainTex_TexelSize变量,以计算相邻像素的纹理坐标偏移量。

  4. 定义两个Pass使用的顶点着色器

    1
    2
    3
    4
    struct v2f {
    float4 pos : SV_POSITION;
    half2 uv[5]: TEXCOORD0;
    };

    以下是竖直方向上的顶点着色器代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    v2f vertBlurVertical(appdata_img v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    half2 uv = v.texcoord;

    o.uv[0] = uv;
    o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
    o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
    o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;
    o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;

    return o;
    }
    • 在本节中我们会利用5x5大小的高斯核对原图像进行高斯模糊,一个5x5的二维高斯核可以拆分成两个大小为5的一维高斯核,因此我们只需要计算5个纹理坐标即可。
    • 我们在v2f中存储了当前的采样纹理坐标,数组的第一个坐标存储了当前的采样坐标,而剩余的4个坐标则是高斯模糊中对邻域采样时使用的纹理坐标。

    以下是水平方向上的顶点着色器代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    v2f vertBlurHorizontal(appdata_img v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    half2 uv = v.texcoord;

    o.uv[0] = uv;
    o.uv[1] = uv + float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
    o.uv[2] = uv - float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
    o.uv[3] = uv + float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;
    o.uv[4] = uv - float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;

    return o;
    }

    水平方向的顶点着色器和上面的代码类似,只是在计算4个纹理坐标时使用了水平方向的纹素大小进行纹理偏移。

  5. 定义了两个Pass共用的片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    fixed4 fragBlur(v2f i) : SV_Target {
    float weight[3] = {0.4026, 0.2442, 0.0545};

    fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];

    for (int it = 1; it < 3; it++) {
    sum += tex2D(_MainTex, i.uv[it*2-1]).rgb * weight[it];
    sum += tex2D(_MainTex, i.uv[it*2]).rgb * weight[it];
    }

    return fixed4(sum, 1.0);
    }
    • 一个5×5的二维高斯核可以拆分成两个大小为5的一维高斯核,并且由于它的对称性,我们只需要记录3个高斯权重,也就是代码中的 weight变量。
    • 我们首先声明了各个邻域像素对应的权重weight,然后将结果值sum初始化为当前的像素值乘以它的权重值。根据对称性,我们进行了两次迭代,每次迭代包含了两次纹理采样,并把像素值和权重相乘后的结果叠加到sum 中。
    • 最后,函数返回滤波结果 sum。
  6. 定义高斯模糊需要的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    Pass
    {
    Name "GAUSSIAN_BLUR_VERTICAL"

    CGPROGRAM

    #pragma vertex vertBlurVertical
    #pragma fragment fragBlur

    ENDCG
    }

    Pass
    {
    NAME "GAUSSIAN_BLUR_HORIZONTAL"

    CGPROGRAM

    #pragma vertex vertBlurHorizontal
    #pragma fragment fragBlur

    ENDCG
    }
  7. 最后关闭FallBack

    1
    Fallback Off

11.5 - Bloom效果

Bloom效果可以让画面中较亮的区域扩散到周围,营造一种朦胧的氛围。

Bloom的实现原理非常简单,

  1. 我们首先根据一个阈值提取出屏幕中较亮的区域,把他们存储到一张渲染纹理中
  2. 利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果
  3. 最后将这种渲染纹理和原图混合

Script:Bloom

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader BloomShader = null;
    private Material BloomMaterial = null;

    public Material material
    {
    get
    {
    BloomMaterial = CheckShaderAndCreateMaterial(BloomShader, BloomMaterial);
    return BloomMaterial;
    }
    }
  3. 在脚本中,我们提供了调整高斯模糊迭代次数、模糊范围和缩放系数的参数以及用来控制提取较量区域的阈值luminanceThreshold

    1
    2
    3
    4
    5
    6
    7
    8
    [Range(0f, 4f)]
    public int iterations = 3;
    [Range(0.2f, 3.0f)]
    public float blurSpread = 0.6f;
    [Range(1f, 8f)]
    public int downSample = 2;
    [Range(0.0f, 4.0f)]
    public float luminanceThreshold = 0.6f;

    尽管在绝大多数情况下,图像的亮度值不会超过1。但如果我们开启了HDR,硬件会允许我们把颜色值存储在一个更高精度范围的缓冲中,此时像素的亮度值可能会超过1。因此,在这里我们把luminanceThreshold 的值规定在[0,4]范围内。更多关于HDR的内容,可以参见18.4.3节。

  4. 定义OnRenderImage来进行特效处理

    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
    32
    33
    34
    35
    36
    37
    38
    39
    void OnRenderImage (RenderTexture src, RenderTexture dest) {
    if (material != null) {
    material.SetFloat("_LuminanceThreshold", luminanceThreshold);

    int rtW = src.width/downSample;
    int rtH = src.height/downSample;

    RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
    buffer0.filterMode = FilterMode.Bilinear;

    Graphics.Blit(src, buffer0, material, 0);

    for (int i = 0; i < iterations; i++) {
    material.SetFloat("_BlurSize", 1.0f + i * blurSpread);

    RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

    // Render the vertical pass
    Graphics.Blit(buffer0, buffer1, material, 1);

    RenderTexture.ReleaseTemporary(buffer0);
    buffer0 = buffer1;
    buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

    // Render the horizontal pass
    Graphics.Blit(buffer0, buffer1, material, 2);

    RenderTexture.ReleaseTemporary(buffer0);
    buffer0 = buffer1;
    }

    material.SetTexture ("_Bloom", buffer0);
    Graphics.Blit (src, dest, material, 3);

    RenderTexture.ReleaseTemporary(buffer0);
    } else {
    Graphics.Blit(src, dest);
    }
    }

    上面的代码和高斯模糊中的几乎一致,做了以下改动

    • 首先提取图像中较亮的部分,使用Pass0进行这个过程。
    • 然后进行高斯模糊迭代,对应Pass1Pass2
    • 然后再把模糊后的较亮区域存储在buffer0中,传递给材质中的**_Bloom属性,然后使用Pass3**进行混合,将混合的结果存储在目标渲染纹理dest中。

Shader:Chapter12-Bloom

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _BlueSize("Blue Size", Float) = 1.0
    _Bloom ("Bloom", 2D) = "black" {}
    _LuminanceThreshold ("Luminance Threshold", Float) = 0.5
    }
  2. 使用CGINCLUDE来组织代码,可以复用我们已经写过的相关函数。

    1
    2
    3
    4
    5
    SubShader
    {
    CGINCLUDE
    ...
    ENDCG
  3. 定义以下变量

    1
    2
    3
    4
    5
    sampler2D _MainTex;    
    half4 _MainTex_TexelSize;
    float _BlurSize;
    sampler2D _Bloom;
    float _LuminanceThreshold;

    由于要得到相邻像素的纹理坐标,我们这里再一次使用了Unity 提供的_MainTex_TexelSize变量,以计算相邻像素的纹理坐标偏移量。

  4. 定义提取较亮区域Pass0需要使用的顶点和片元着色器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    struct v2f {
    float4 pos : SV_POSITION;
    half2 uv : TEXCOORD0;
    };

    v2f vertExtractBright(appdata_img v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord;
    return o;
    }

    fixed luminance(fixed4 color) {
    return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
    }

    fixed4 fragExtractBright(v2f i) : SV_Target {
    fixed4 c = tex2D(_MainTex, i.uv);
    fixed val = clamp(luminance(c) - _LuminanceThreshold, 0.0, 1.0);

    return c * val;
    }
    • 在片元着色器中,我们将采样得到的亮度值减去阈值**_LuminanceThreshold**,并将结果截取到0~1之间。
    • 然后,我们将该值与原像素相乘,得到提取后的亮部区域。
  5. 定义了混合两部图像和原图像时使用的顶点着色器和片元着色器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    struct v2fBloom {
    float4 pos : SV_POSITION;
    half4 uv : TEXCOORD0;
    };

    v2fBloom vertBloom(appdata_img v) {
    v2fBloom o;

    o.pos = UnityObjectToClipPos (v.vertex);
    o.uv.xy = v.texcoord;
    o.uv.zw = v.texcoord;

    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0.0)
    o.uv.w = 1.0 - o.uv.w;
    #endif

    return o;
    }

    fixed4 fragBloom(v2fBloom i) : SV_Target {
    return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);
    }
    • 这里使用的顶点着色器与之前的有所不同,我们定义了两个纹理坐标,并存储在同一个类型为 half4的变量uv中。它的xy分量对应了_MainTex,即原图像的纹理坐标。而它的zw分量是**_Bloom**,即模糊后的较亮区域的纹理坐标。我们需要对这个纹理坐标进行平台差异化处理(详见5.6.1节)。
  6. 定义了Bloom效果需要的4个Pass

    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
    // No culling or depth
    Cull Off ZWrite Off ZTest Always

    Pass
    {
    CGPROGRAM

    #pragma vertex vertExtractBright
    #pragma fragment fragExtractBright

    ENDCG
    }

    UsePass "Shader Learning/Chapter12/Chapter12-GaussianBlur/GAUSSIAN_BLUR_VERTICAL"

    UsePass "Shader Learning/Chapter12/Chapter12-GaussianBlur/GAUSSIAN_BLUR_HORIZONTAL"

    Pass
    {
    CGPROGRAM

    #pragma vertex vertBloom
    #pragma fragment fragBloom

    ENDCG
    }
    }

    需要注意的是,由于 Unity内部会把所有Pass 的 Name转换成大写字母表示,因此在使用UsePass命令时我们必须使用大写形式的名字。

  7. 最后关闭FallBack

    1
    Fallback Off

11.6 - 运动模糊

运动模糊的实现有多种方法。一种实现方法是利用一块**累积缓存(accumulation buffer )**来混合多张连续的图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。

另一种应用广泛的方法是创建和使用**速度缓存(velocitybuffer)**,这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

在本节中,我们采用第一种方法:累积缓存来实现运动模糊的效果。

Script:MotionBlur

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader MotionBlurShader = null;
    private Material MotionBlurMaterial = null;

    public Material material
    {
    get
    {
    MotionBlurMaterial = CheckShaderAndCreateMaterial(MotionBlurShader, MotionBlurMaterial);
    return MotionBlurMaterial;
    }
    }
  3. blurAmount的值越大,运动拖尾的效果就越明显。同时定义一个RenderTexture类型的变量accumulationTexture,用来保存之前图像叠加的结果。

    1
    2
    3
    4
    5
    6
    7
    8
    [Range(0.0f, 9.0f)] 
    public float blurAmount = 0.5f;
    private RenderTexture accumulationTexture;

    private void OnDisable()
    {
    DestroyImmediate(accumulationTexture);
    }

    在上面的代码里,我们在该脚本不运行时,即调用OnDisable函数时,立即销毁accumulationTexture。这是因为,我们希望在下一次开始应用运动模糊时重新叠加图像。

  4. 定义OnRenderImage来进行特效处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
    if (material != null)
    {
    if (accumulationTexture == null || accumulationTexture.width != src.width ||
    accumulationTexture.height != src.height)
    {
    DestroyImmediate(accumulationTexture);
    accumulationTexture = new RenderTexture(src.width, src.height, 0);
    accumulationTexture.hideFlags = HideFlags.HideAndDontSave;
    Graphics.Blit(src, accumulationTexture);
    }

    accumulationTexture.MarkRestoreExpected();

    material.SetFloat("_BlurAmount", 1.0f - blurAmount);

    Graphics.Blit(src, accumulationTexture, material);
    Graphics.Blit(accumulationTexture, dest);
    }
    else
    {
    Graphics.Blit(src, dest);
    }
    • 在确认材质可用后,我们首先判断用于混合图像的accumulationTexture是否满足条件。我们不仅判断它是否为空,还判断它是否与当前的屏幕分辨率相等,如果不满足,就说明我们需要重新创建一个适合于当前分辨率的accumulationTexture变量。
    • 创建完毕后,由于我们会自己控制该变量的销毁,因此可以把它的 hideFlags设置为 HideFlags.HideAndDontSave
      这意味着这个变量不会显示在Hierarchy中,也不会保存到场景中。
    • 然后,我们使用当前的帧图像初始化 accumulationTexture
    • 当得到了有效的 accumulationTexture变量后,我们调用了accumulationTexture.MarkRestoreExpected函数来表明我们需要进行一个渲染纹理的恢复操作恢复操作(restore operation)发生在渲染到纹理而该纹理又没有被提前清空或销毁的情况下。在本例中,我们每次调用OnRenderImage时都需要把当前的帧图像和accumulationTexture 中的图像混合,accumulationTexture纹理不需要提前清空,因为它保存了我们之前的混合结果。
    • 然后,我们将参数传递给材质,并调用Graphics.Blit (src, accumulationTexture, material)把当前的屏幕图像src叠加到accumulationTexture中。最后使用Graphics.Blit (accumulationTexture, dest)把结果显示到屏幕上。

Shader:Chapter12-MotionBlur

  1. 声明以下属性:

    1
    2
    3
    4
    5
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _BlueAmount("Blue Amount", Float) = 1.0
    }
  2. 顶点着色器只需要传递正确的纹理坐标

  3. 定义了两个片元着色器,一个用来更新渲染纹理的RGB通道部分,另一个用于更新渲染纹理的A通道部分

    1
    2
    3
    4
    5
    6
    7
    8
    fixed4 fragRGB(v2f i) : SV_Target{
    return fixed4(tex2D(_MainTex, i.uv).rgb, _BlueAmount);
    }


    half4 fragA(v2f i) : SV_Target{
    return tex2D(_MainTex, i.uv);
    }
    • RGB通道版本的Shader对当前图像进行采样,并将其A通道的值设为_BlurAmount,以便在后面混合时可以使用它的透明通道进行混合。
    • A通道版本的代码就更简单了,直接返回采样结果。实际上,这个版本只是为了维护渲染纹理的透明通道值,不让其受到混合时使用的透明度值的影响。
  4. 定义运动模糊需要的Pass

    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
    // No culling or depth
    ZTest Always Cull Off ZWrite Off

    Pass
    {
    Blend SrcAlpha OneMinusSrcAlpha
    ColorMask RGB

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment fragRGB

    ENDCG
    }

    Pass
    {
    Blend One Zero
    ColorMask A

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment fragA

    ENDCG
    }

  5. 最后关闭FallBack

    1
    Fallback Off

11.7 - 拓展阅读

Unity Image Effect :http:/ldocs.unity3d.com/Manual/comp-ImageEffects.html

GPU Gems 系列:https:/developer.nvidia.com/gpugems/GPUGems

介绍了许多基于图像处理的渲染技术。例如,《GPU Gems 3》的第27章,介绍了一种景深效果的实现方法。

在 Unity 的资源商店和其他网络资源中找到许多出色的屏幕特效。

12 - 深度和法线纹理

12.1 - 获取深度和法线纹理

获取深度+法线纹理

1
2
3
4
private void OnEnable()
{
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}

我们可以在Shader中声明改变量来访问它们。

1
sampler2D _CameraDepthNormalsTexture;

12.2 - 再谈运动模糊

12.3 - 全局雾效

1
2
3
4
5
6
7
// make fog work
#pragma multi_compile_fog

UNITY_FOG_COORDS(1)

// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);

12.3.1 - 重建世界坐标

12.3.2 - 雾的计算

12.3.3 - 实现

Script:FogWithDepthTexture

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader FogWithDepthTextureShader = null;
    private Material FogWithDepthTextureMaterial = null;

    public Material material
    {
    get
    {
    FogWithDepthTextureMaterial = CheckShaderAndCreateMaterial(FogWithDepthTextureShader, FogWithDepthTextureMaterial);
    return FogWithDepthTextureMaterial;
    }
    }
  3. 在本节中,我们需要获取摄像机的相关参数,如近裁剪平面的距离FOV等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向,因此我们用两个变量存储摄像机的Camera组件和 Transform 组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    private Camera myCamera;
    public Camera camera
    {
    get {
    if (myCamera == null) {
    myCamera = GetComponent<Camera>();
    }
    return myCamera;
    }
    }

    private Transform myCameraTransform;
    public Transform cameraTransform {
    get {
    if (myCameraTransform == null) {
    myCameraTransform = camera.transform;
    }
    return myCameraTransform;
    }
    }
  4. 定义模拟雾效时使用的各个参数

    1
    2
    3
    4
    [Range(0.0f, 3.0f)] public float fogDensity = 1.0f;
    public Color fogColor = Color.white;
    public float fogStart = 0.0f;
    public float fogEnd = 2.0f;
    • fogDensity 用于控制雾的浓度,fogColor用于控制雾的颜色。
    • 我们使用的雾效模拟函数是基于高度的,因此参数fogStart用于控制雾效的起始高度fogEnd用于控制雾效的终止高度
  5. 本例需要获取摄像机的深度纹理,在OnEnable中设置摄像机的相应状态。

    1
    2
    3
    4
    private void OnEnable()
    {
    camera.depthTextureMode |= DepthTextureMode.Depth;
    }
  6. 定义OnRenderImage来进行特效处理

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    void OnRenderImage (RenderTexture src, RenderTexture dest) {
    if (material != null) {
    Matrix4x4 frustumCorners = Matrix4x4.identity;

    float fov = camera.fieldOfView;
    float near = camera.nearClipPlane;
    float aspect = camera.aspect;

    float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
    Vector3 toRight = cameraTransform.right * halfHeight * aspect;
    Vector3 toTop = cameraTransform.up * halfHeight;

    Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
    float scale = topLeft.magnitude / near;

    topLeft.Normalize();
    topLeft *= scale;

    Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
    topRight.Normalize();
    topRight *= scale;

    Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
    bottomLeft.Normalize();
    bottomLeft *= scale;

    Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
    bottomRight.Normalize();
    bottomRight *= scale;

    frustumCorners.SetRow(0, bottomLeft);
    frustumCorners.SetRow(1, bottomRight);
    frustumCorners.SetRow(2, topRight);
    frustumCorners.SetRow(3, topLeft);

    material.SetMatrix("_FrustumCornersRay", frustumCorners);

    material.SetFloat("_FogDensity", fogDensity);
    material.SetColor("_FogColor", fogColor);
    material.SetFloat("_FogStart", fogStart);
    material.SetFloat("_FogEnd", fogEnd);

    Graphics.Blit (src, dest, material);
    } else {
    Graphics.Blit(src, dest);
    }
    }
    • OnRenderImage首先计算了近裁剪平面的四个角对应的向量,并把它们存储在一个矩阵类型的变量(frustumCormers)中。
    • 我们按一定顺序把这四个方向存储到了frustumCorners不同的行中,这个顺序是非常重要的,因为这决定了我们在顶点着色器中使用哪行作为该点的待插值向量。
    • 随后,我们把结果和其他参数传递给材质,并调用Graphics.Blit (src, dest, material)把渲染结果显示在屏幕上。

Shader:Chapter13-FogWithDepthTexture

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _FogDensity ("Fog Density", Float) = 1.0
    _FogColor ("Fog Color", Color) = (1, 1, 1, 1)
    _FogStart ("Fog Start", Float) = 0.0
    _FogEnd ("Fog End", Float) = 1.0
    }

    其中,**_Sensitivity**的xy分量分别对应了法线和深度的检测灵敏度,zw分量则没有实际用途。

  2. 声明对应变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    float4x4 _FrustumCornersRay;

    sampler2D _MainTex;
    half4 _MainTex_TexelSize;
    sampler2D _CameraDepthTexture;
    half _FogDensity;
    fixed4 _FogColor;
    float _FogStart;
    float _FogEnd;

    FrustumCornersRay虽然没有在 Properties中声明,但仍可由脚本传递给Shader。

    除了在上面的代码中,我们还声明了需要获取的深度纹理**_CameraDepthNormalsTexture**。

  3. 定义顶点着色器

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    struct v2f
    {
    half2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    half2 uv_depth : TEXCOORD1;
    float4 interpolatedRay : TEXCOORD2;
    };

    v2f vert (appdata_img v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord;
    o.uv_depth = v.texcoord;

    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
    {
    o.uv_depth.y = 1 - o.uv_depth.y;
    }
    #endif

    int index = 0;
    if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
    {
    index = 0;
    }
    else if(v.texcoord.x > 0.5 && v.texcoord.y < 0.5)
    {
    index = 1;
    }else if(v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
    {
    index = 2;
    }
    else
    {
    index = 3;
    }

    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
    {
    index = 3 - index;
    }
    #endif

    o.interpolatedRay = _FrustumCornersRay[index];

    return o;
    }
    • 在 v2f 结构体中,我们除了定义顶点位置、屏幕图像和深度纹理的纹理坐标外,还定义了interpolatedRay变量存储插值后的像素向量。
    • 在顶点着色器中,我们对深度纹理的采样坐标进行了平台差异化处理。更重要的是,我们要决定该点对应了4个角中的哪个角。我们采用的方法是判断它的纹理坐标。我们知道,在Unity中,纹理坐标的(0,0)点对应了左下角,而(1,1)点对应了右上角。我们据此来判断该顶点对应的索引,这个对应关系和我们在脚本中对frustumCorners 的赋值顺序是一致的。实际上,不同平台的纹理坐标不一定是满足上面的条件的,例如 DirectX和Metal 这样的平台,左上角对应了(0,0)点,但大多数情况下Unity 会把这些平台下的屏幕图像进行翻转,因此我们仍然可以利用这个条件。但如果在类似 DirectX 的平台上开启了抗锯齿,Unity就不会进行这个翻转。为了此时仍然可以得到相应顶点位置的索引值,我们对索引值也进行了平台差异化处理(详见5.6.1节),以便在必要时也对索引值进行翻转。
    • 最后,我们使用索引值来获取 FrustumCornersRay 中对应的行作为该顶点的interpolatedRay值。
    • 尽管我们这里使用了很多判断语句,但由于屏幕后处理所用的模型是一个四边形网格,只包含4个顶点,因此这些操作不会对性能造成很大影响。
  4. 定义片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    fixed4 frag(v2f i) : SV_Target {

    float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
    float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;

    float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
    fogDensity = saturate(fogDensity * _FogDensity);

    fixed4 finalColor = tex2D(_MainTex, i.uv);
    finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);

    return finalColor;
    }
    • 首先,我们需要重建该像素在世界空间中的位置。为此,我们首先使用SAMPLE_DEPTH_TEXTURE对深度纹理进行采样,再使用LinearEyeDepth得到视角空间下的线性深度值。之后,与interpolatedRay相乘后再和世界空间下的摄像机位置相加,即可得到世界空间下的位置。
    • 得到世界坐标后,模拟雾效就变得非常容易。在本例中,我们选择实现基于高度的雾效模拟,计算公式可参见13.3.2节。我们根据材质属性**_FogEnd_FogStart计算当前的像素高度worldPos.y对应的雾效系数fogDensity,再和参数 FogDensity 相乘后,利用saturate**函数截取到[0,1]范围内,作为最后的雾效系数。然后,我们使用该系数将雾的颜色和原始颜色进行混合后返回。也可以使用不同的公式来实现其他种类的雾效。
  5. 然后,我们定义雾效渲染需要用到的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass {
    ZTest Always Cull Off ZWrite Off

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    ENDCG
    }
  6. 最后关闭FallBack

    1
    Fallback Off

本节介绍的使用深度纹理重建像素的世界坐标的方法是非常有用的。但需要注意的是,这里的实现是基于摄像机的投影类型是透视投影的前提下。如果需要在正交投影的情况下重建世界坐标,需要使用不同的公式。

12.4 - 再谈边缘检测

在上一章,我们使用Sobel算子实现了屏幕图像的边缘检测,实现描边效果,但是这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线。物体的纹理、阴影等位置也被描上黑边,而这往往不是我们希望看到的。在本节中,我们将学习如何在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。

本节将使用Roberts算子来进行边缘检测,它的卷积核如图所示:

image-20230714112922450

Roberts算子的本质是计算左上角和右下角的差值,乘以右上角和左下角的插值,作为评估边缘的依据。

在实现中,我们将取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阈值,就认为它们之间存在一条边。

Script:EdgeDetectNormalsAndDepth

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader EdgeDetectShader = null;
    private Material EdgeDetectMaterial = null;

    public Material material
    {
    get
    {
    EdgeDetectMaterial = CheckShaderAndCreateMaterial(EdgeDetectShader, EdgeDetectMaterial);
    return EdgeDetectMaterial;
    }
    }
  3. 在脚本中提供调整边缘线强度、描边颜色以及背景颜色的参数。

    1
    2
    3
    4
    5
    6
    7
    [Range(0.0f, 1.0f)] public float edgesOnly = 0.0f;

    public Color edgeColor = Color.black;
    public Color backgroundColor = Color.white;
    public float sampleDistance = 1.0f;
    public float sensitivityDepth = 1.0f;
    public float sensitivityNormals = 1.0f;

    sampleDistance用于控制深度+法线纹理采样时,使用的采样距离。sampleDistance越大,描边越宽。

    sensitivityDepthsensitivityNormals将会影响被认为是边界的阈值。如果把灵敏度调得很大,那么可能即使是深度或法线上很小的变化也会形成一条边。

  4. 本例需要获取摄像机的深度+法线纹理,在OnEnable中设置摄像机的相应状态。

    1
    2
    3
    4
    private void OnEnable()
    {
    GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
    }
  5. 定义OnRenderImage来进行特效处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    [ImageEffectOpaque]
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
    if (material != null)
    {
    material.SetFloat("_EdgeOnly", edgesOnly);
    material.SetColor("_EdgeColor", edgeColor);
    material.SetColor("_BackgroundColor", backgroundColor);
    material.SetFloat("_SampleDistance", sampleDistance);
    material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));

    Graphics.Blit(src, dest, material);
    } else {
    Graphics.Blit(src, dest);
    }
    }

    需要注意的是,这里我们为OnRenderImage函数添加了**[ImageEffectOpaque]**属性。

    在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass(即渲染队列小于等于2500的 Pass,内置的 Background、 Geometry和 AlphaTest渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent 的 Pass)产生影响,可以在OnRenderImage函数前添加 ImageEffectOpaque属性来实现这样的目的。

    在本例中,我们只希望对不透明物体进行描边,而不希望透明物体也被描边,因此需要添加该属性。

Shader:Chapter13-EdgeDetection

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _EdgeOnly ("Edge Only", Float) = 1.0
    _EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
    _BackgroundColor ("_ackground Color", Color) = (1, 1, 1, 1)
    _SampleDistance ("Sample Distance", Float) = 1.0
    _Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
    }

    其中,**_Sensitivity**的xy分量分别对应了法线和深度的检测灵敏度,zw分量则没有实际用途。

  2. 声明对应变量

    1
    2
    3
    4
    5
    6
    7
    8
    sampler2D _MainTex;
    half4 _MainTex_TexelSize;
    fixed _EdgeOnly;
    fixed4 _EdgeColor;
    fixed4 _BackgroundColor;
    float _SampleDistance;
    half4 _Sensitivity;
    sampler2D _CameraDepthNormalsTexture;

    在上面的代码中,我们声明了需要获取的深度+法线纹理**_CameraDepthNormalsTexture。由于我们需要对邻域像素进行纹理采样,所以还声明了存储纹素大小的变量_MainTex_TexelSize**。

  3. 定义顶点着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    v2f vert (appdata_img v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    half2 uv = v.texcoord;
    o.uv[0] = uv;

    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
    uv.y = 1 - uv.y;
    #endif

    o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
    o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
    o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
    o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;

    return o;
    }
    • 我们在 v2f结构体中定义了一个维数为5的纹理坐标数组。这个数组的第一个坐标存储了屏幕颜色图像的采样纹理。
    • 我们对深度纹理的采样坐标进行了平台差异化处理,在必要情况下对它的竖直方向进行了翻转。
    • 数组中剩余的4个坐标则存储了使用 Roberts算子时需要采样的纹理坐标。
    • 我们还使用了SampleDistance来控制采样距离。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
  4. 定义片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
    half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
    half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
    half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
    half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

    half edge = 1.0;

    edge *= CheckSame(sample1, sample2);
    edge *= CheckSame(sample3, sample4);

    fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
    fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);

    return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
    }

    我们首先使用4个纹理坐标对深度+法线纹理进行采样,再调用CheckSame函数来分别计算对角线上两个纹理值的差值CheckSame函数的返回值要么是0,要么是1,返回0时表明这两点之间存在一条边界,反之则返回1。

    它的定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    half CheckSame(half4 center, half4 sample)
    {
    half2 centerNormal = center.xy;
    float centerDepth = DecodeFloatRG(center.zw);
    half2 sampleNormal = sample.xy;
    float sampleDepth = DecodeFloatRG(sample.zw);

    half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
    int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;

    float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;

    int isSameDepth = diffDepth < 0.1 * centerDepth;

    // return:
    // 1 - if normals and depth are similar enough
    // 0 - otherwise
    return isSameNormal * isSameDepth ? 1.0 : 0.0;

    }
    • CheckSame首先对输入参数进行处理,得到两个采样点的法线和深度值。

      值得注意的是,这里我们并没有解码得到真正的法线值,而是直接使用了xy分量。这是因为我们只需要比较两个采样值之间的差异,而不需要知道它真正的法线值。

    • 然后,我们把两个采样点的对应值相减并取绝对值,再乘以灵敏度系数,把差异值的每个分量相加在和一个阈值比较

      如果它们的和小于阈值,则返回1,说明差异不明显,不存在一条边界

      否则返回0,说明存在一条边界。

  5. 然后,我们定义边缘检测需要用到的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass {
    ZTest Always Cull Off ZWrite Off

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment fragRobertsCrossDepthAndNormal

    ENDCG
    }
  6. 最后关闭FallBack

    1
    Fallback Off

本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后,我们想要在该物体周围添加一层描边效果。这时,我们可以使用 Unity提供的Graphics.DrawMeshGraphics.DrawMeshNow函数把需要描边的物体再次渲染一遍(在所有不透明物体渲染完毕之后),然后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否4小于某个阈值,如果是,就在Shader 中使用clip()函数将该像素剔除掉,从而显示出原来的物体颜色。

13 - 非真实感渲染

13.1 - 卡通渲染

卡通风格是游戏中常见的一种渲染风格。使用这种风格的游戏画面通常有一些共有的特点,例如物体都被黑色的线条描边,以及分明的明暗变化等。

要实现卡通渲染有很多方法,其中之一就是使用基于色调的着色技术(tone-based shading)

Gooch等人在他们1998年的一篇论文中提出并实现了基于色调的光照模型。在实现中,我们往往会使用漫反射系数对一张一维纹理进行采样,以控制漫反射的色调。我们曾在7.3节使用渐变纹理实现过这样的效果。

卡通风格的高光效果也和我们之前学习的光照不同。在卡通渲染中,模型的高光往往是一块块分界明显的纯色区域。

除了光照模型不同外,卡通风格通常还需要在物体边缘部分绘制轮廓。

13.1.1 - 渲染轮廓线

在实时渲染中,轮廓线的渲染是应用非常广泛的一种效果。近20年来,有许多绘制模型轮廓线的方法被先后提出来。在《Real Time Rendering,third edition》一书中,作者把这些方法分成了5种类型。

  • 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息。这种方法简单快速可以在一个 Pass中就得到渲染结果,但局限性很大,很多模型渲染出来的描边效果都不尽如人意。

  • 过程式几何轮廓线渲染。这种方法的核心是使用两个Pass渲染。第一个 Pass渲染背面的面片,并使用某些技术让它的轮廓可见;第二个 Pass再正常渲染正面的面片。这种方法的优点在于快速有效,并且适用于绝大数表面平滑的模型,但它的缺点是不适合类似于立方体这样平整的模型。

  • 基于图像处理的轮廓线渲染。我们在第12、13章介绍的边缘检测的方法就属于这个类别。这种方法的优点在于,可以适用于任何种类的模型。但它也有自身的局限所在,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。

  • 基于轮廓边检测的轮廓线渲染。上面提到的各种方法,一个最大的问题是,无法控制轮廓线的风格渲染。对于一些情况,我们希望可以渲染出独特风格的轮廓线,例如水墨风格等。为此,我们希望可以检测出精确的轮廓边,然后直接渲染它们。检测一条边是否是轮廓边的公式很简单,我们只需要检查和这条边相邻的两个三角面片是否满足以下条件:
    $$
    (n_0 \cdot v > 0) \neq (n_1 \cdot v > 0)
    $$

    其中,n0和n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意顶点的方向。

    上述公式的本质在于检查两个相邻的三角面片是否一个朝正面、一个朝背面。

    我们可以在几何着色器(Geometry Shader)的帮助下实现上面的检测过程。当然,这种方法也有缺点,除了实现相对复杂外,它还会有动画连贯性的问题。也就是说,由于是逐帧单独提取轮廓,所以在帧与帧之间会出现跳跃性。

  • 最后一个种类就是混合了上述的几种渲染方法。例如,首先找到精确的轮廓边把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

在本节中,我们将会在Unity中使用过程式几何轮廓线渲染的方法来对模型进行轮廓描边。

我们将使用两个Pass渲染模型:在第一个Pass中,我们会使用轮廓线颜色渲染整个背面的面片,并在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此来让背部轮廓线可见。代码如下:

1
viewPos = viewPos + viewNormal * _Outline;

但是,如果直接使用顶点法线进行扩展,对于一些内凹的模型,就可能发生面面片的情况。

为了尽可能防止出现这样的情况,在扩张背面顶点之前,我们首先对顶点法线的z分量进行处理,使它们等于一个定值
然后把法线归一化后再对顶点进行扩张。

这样做的好处在于,扩展后的背面更加扁平化,从而降低了遮挡正面面片的可能性。代码如下:

1
2
3
viewNormal.z = -0.5;
viewNormal = normalize(viewNormal) ;
viewPos = viewPos + viewNormal * Outline;

13.1.2 - 添加高光

前面提到过,卡通风格中的高光往往是模型上一块块分界明显的纯色区域。为了实现这种效果,我们就不能再使用之前学习的光照模型。回顾一下,在之前实现 Blinn-Phong模型的过程中,我们使用法线点乘光照方向以及视角方向和的一半,再和另一个参数进行指数操作得到高光反射系数。代码如下:

1
float spec = pow(max(0, dot(normal,halfDir)), Gloss)

对于卡通渲染需要的高光反射光照模型,我们同样需要计算normal和 halfDir的点乘结果,但不同的是,我们把该值和一个阈值进行比较,如果小于该阙值,则高光反射系数为0,否则返回1。

1
2
float spec = dot(worldNormal, worldHalfDir);
spec = step(threshold, spec);

在上面的代码中,我们使用CG的step函数来实现和阈值比较的目的。step函数接受两个参数,第一个参数是参考值,第二个参数是待比较的数值。如果第二个参数大于等于第一个参数则返回1,否则返回0。

但是,这种粗暴的判断方法会在高光区域的边界造成锯齿,如图14.3左图所示。出现这种问题的原因在于,高光区域的边缘不是平滑渐变的,而是由0突变到1。要想对其进行抗锯齿处理,我们可以在边界处很小的一块区域内,进行平滑处理。代码如下:

1
2
float spec = dot(worldNormal, worldHalfDir);
spec = lerp(0, 1, smoothstep(-w, w, spec - threshold));

在上面的代码中,我们没有像之前一样直接使用step函数返回0或1,而是首先使用了CG的smoothstep函数。其中,w是一个很小的值,当spec - threshold小于-w时,返回0,大于w时,返回1,否则在0到1之间进行插值。

这样的效果是,我们可以在[-w,w]区间内,即高光区域的边界处,得到一个从0到1平滑变化的spec值,从而实现抗锯齿的目的。尽管我们可以把w设为一个很小的定值,但在本例中,我们选择使用邻域像素之间的近似导数值,这可以通过CG的fwidth函数来得到。

13.1.3 - 实现

Shader:Chapter14-ToonShader

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _Color ("Color Tine", Color) = (1, 1, 1, 1)
    _Ramp ("Ramp Texture", 2D) = "white" {}
    _Outline ("Outline", Range(0, 1)) = 0.1
    _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1)
    _Specular ("Specular", Color) = (1, 1, 1, 1)
    _SpecularScale ("Specular Scale", Range(0, 0.1)) = 0.1

    }

    Ramp是用于控制漫反射色调的渐变纹理

    Outline用于控制轮廓线宽度

    _OutlineColor对应了轮廓线颜色

    Specular 是高光反射颜色

    SpecularScale用于控制计算高光反射时使用的阈值。

  2. 定义渲染轮廓线需要的Pass,这个Pass只渲染背面的三角面,所以需要进行以下设置:

    1
    2
    3
    4
    Pass
    {
    NAME "OUTLINE"
    Cull Front
  3. 定义描边需要的顶点着色器和片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    //法线外扩
    v2f vert (appdata v)
    {
    v2f o;

    float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
    float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
    normal.z = -0.5;
    pos = pos + float4(normalize(normal), 0) * _Outline;
    o.pos = mul(UNITY_MATRIX_P, pos);

    return o;
    }
    fixed4 frag (v2f i) : SV_Target
    {
    return fixed4(fixed4(_OutlineColor.rgb, 1));
    }
    • 在顶点着色器中我们首先把顶点和法线变换到视角空间下,这是为了让描边可以在观察空间达到最好的效果。
    • 随后,我们设置法线的z分量,对其归一化后再将顶点沿其方向扩张,得到扩张后的顶点坐标。对法线的处理是为了尽可能避免背面扩张后的顶点挡住正面的面片。
    • 最后,我们把顶点从视角空间变换到裁剪空间。
    • 片元着色器只需要返回描线的颜色即可。
  4. 然后,我们要定义光照模型所在的Pass,以渲染模型的正面。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    Cull Back

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_fwdbase

    在上面的代码中,我们将LightMode设置为ForwardBase,并且使用#pragma语句设置了编译指令,这些都是为了让 Shader中的光照变量可以被正确赋值。

  5. 然后,我们定义顶点着色器

    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
    struct a2v
    {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 tangent : TANGENT;
    };

    struct v2f {
    float4 pos : POSITION;
    float2 uv : TEXCOORD0;
    float3 worldNormal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    SHADOW_COORDS(3)
    };


    v2f vert (a2v v) {
    v2f o;

    o.pos = UnityObjectToClipPos( v.vertex);
    o.uv = TRANSFORM_TEX (v.texcoord, _MainTex);
    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    TRANSFER_SHADOW(o);

    return o;
    }

    在上面的代码中,我们计算了世界空间下的法线方向和顶点位置,并使用Unity提供的内置宏SHADOW_COORDSTRANSFER_SHADOW来计算阴影所需的各个变量。

  6. 然后我们在片元着色器中计算光照模型

    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
    fixed4 frag (v2f i) : SV_Target
    {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    fixed3 worldviewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + worldviewDir);

    fixed4 c = tex2D(_MainTex, i.uv);
    fixed3 albedo = c.rgb * _Color.rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    fixed diff = dot(worldNormal, worldLightDir);
    diff = (diff * 0.5 + 0.5) * atten;

    fixed3 diffuse = _LightColor0.rgb * albedo * tex2D(_Ramp, float2(diff, diff)).rgb;


    fixed spec = dot(worldNormal, halfDir);
    fixed w = fwidth(spec) * 2.0;
    fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(0.0001, _SpecularScale);


    return fixed4(ambient + diffuse + specular, 1.0);
    }
    • 首先,我们计算了光照模型中需要的各个方向矢量,并对它们进行了归一化处理。
    • 然后,我们计算了材质的反射率albedo环境光照ambient
      接着,我们使用内置的UNITY_LIGHT_ATTENUATION宏来计算当前世界坐标下的阴影值。
    • 随后,我们计算了半兰伯特漫反射系数,并和阴影值相乘得到最终的漫反射系数。我们使用这个漫反射系数对渐变纹理Ramp进行采样,并将结果和材质的反射率、光照颜色相乘,作为最后的漫反射光照。
    • 高光反射的计算和14.1.2节中介绍的方法一致,我们使用fwidth对高光区域的边界进行抗锯齿处理,并将计算而得的高光反射系数和高光反射颜色相乘,得到高光反射的光照部分。
    • 值得注意的是,我们在最后还使用了step(0.0001,_SpecularScale),这是为了在**_SpecularScale**为0时,可以完全消除高光反射的光照。
    • 最后,返回环境光照、漫反射光照和高光反射光照叠加的结果。
  7. 最后选择合适的FallBack

    1
    FallBack "Diffuse"

13.2 - 素描风格的渲染

Shader:Chapter14-ToonShader

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Properties {
    _Color ("Color Tint", Color) = (1, 1, 1, 1)
    _TileFactor ("Tile Factor", Float) = 1
    _Outline ("Outline", Range(0, 1)) = 0.1
    _Hatch0 ("Hatch 0", 2D) = "white" {}
    _Hatch1 ("Hatch 1", 2D) = "white" {}
    _Hatch2 ("Hatch 2", 2D) = "white" {}
    _Hatch3 ("Hatch 3", 2D) = "white" {}
    _Hatch4 ("Hatch 4", 2D) = "white" {}
    _Hatch5 ("Hatch 5", 2D) = "white" {}
    }

    其中,Color是用于控制模型颜色的属性。**_TileFactor是纹理的平铺系数,_TileFactor越大,模型上的素描线条越密,在实现图14.5的过程中,我们把_TileFactor** 设置为8。Hatch0至**_Hatch5**对应了渲染时使用的6张素描纹理,它们的线条密度依次增大。

  2. 由于素描风格也需要轮廓线,我们使用上一节中的Pass

    1
    2
    3
    4
    5
    SubShader
    {
    Tags { "RenderType"="Opaque" "Queue"="Geometry"}

    UsePass "Shader Learning/Chapter14/Chapter14-ToonShader/OUTLINE"
  3. 定义光照模型所在的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_fwdbase
  4. 声明参数,定义顶点着色器的输入输出结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    fixed4 _Color;
    float _TileFactor;
    sampler2D _Hatch0;
    sampler2D _Hatch1;
    sampler2D _Hatch2;
    sampler2D _Hatch3;
    sampler2D _Hatch4;
    sampler2D _Hatch5;

    struct a2v {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float2 texcoord : TEXCOORD0;
    };

    struct v2f {
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    fixed3 hatchWeights0 : TEXCOORD1;
    fixed3 hatchWeights1 : TEXCOORD2;
    float3 worldPos : TEXCOORD3;
    SHADOW_COORDS(4)
    };

    由于一共声明了6张纹理,这意味着需要6个混合权重,我们把它们存储在两个fixed3类型的变量(hatchWeights0hatchWeights1)中。为了添加阴影效果,我们还声明了worldPos变量,并使用SHADOW_COORDS 宏声明了阴影纹理的采样坐标。

  5. 然后,我们定义顶点着色器

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    v2f vert(a2v v) {
    v2f o;

    o.pos = UnityObjectToClipPos(v.vertex);

    o.uv = v.texcoord.xy * _TileFactor;

    fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex));
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
    fixed diff = max(0, dot(worldLightDir, worldNormal));

    o.hatchWeights0 = fixed3(0, 0, 0);
    o.hatchWeights1 = fixed3(0, 0, 0);

    float hatchFactor = diff * 7.0;

    if (hatchFactor > 6.0) {
    // Pure white, do nothing
    } else if (hatchFactor > 5.0) {
    o.hatchWeights0.x = hatchFactor - 5.0;
    } else if (hatchFactor > 4.0) {
    o.hatchWeights0.x = hatchFactor - 4.0;
    o.hatchWeights0.y = 1.0 - o.hatchWeights0.x;
    } else if (hatchFactor > 3.0) {
    o.hatchWeights0.y = hatchFactor - 3.0;
    o.hatchWeights0.z = 1.0 - o.hatchWeights0.y;
    } else if (hatchFactor > 2.0) {
    o.hatchWeights0.z = hatchFactor - 2.0;
    o.hatchWeights1.x = 1.0 - o.hatchWeights0.z;
    } else if (hatchFactor > 1.0) {
    o.hatchWeights1.x = hatchFactor - 1.0;
    o.hatchWeights1.y = 1.0 - o.hatchWeights1.x;
    } else {
    o.hatchWeights1.y = hatchFactor;
    o.hatchWeights1.z = 1.0 - o.hatchWeights1.y;
    }

    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    TRANSFER_SHADOW(o);

    return o;
    }
    • 我们首先对顶点进行了基本的坐标变换。
    • 然后,使用**_TileFactor** 得到了纹理采样坐标。
    • 在计算6张纹理的混合权重之前,我们首先需要计算逐顶点光照。因此,我们使用世界空间下的光照方向和法线方向得到漫反射系数diff。
    • 之后,我们把权重值初始化为0,并把diff缩放到[0,7]范围,得到 hatchFactor。我们把[0,7]的区间均匀划分为7个子区间,通过判断hatchFactor 所处的子区间来计算对应的纹理混合权重。
    • 最后,我们计算了顶点的世界坐标,并使用TRANSFER_SHADOW宏来计算阴影纹理的采样坐标。
  6. 然后我们在片元着色器中计算光照模型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    fixed4 frag(v2f i) : SV_Target {			
    fixed4 hatchTex0 = tex2D(_Hatch0, i.uv) * i.hatchWeights0.x;
    fixed4 hatchTex1 = tex2D(_Hatch1, i.uv) * i.hatchWeights0.y;
    fixed4 hatchTex2 = tex2D(_Hatch2, i.uv) * i.hatchWeights0.z;
    fixed4 hatchTex3 = tex2D(_Hatch3, i.uv) * i.hatchWeights1.x;
    fixed4 hatchTex4 = tex2D(_Hatch4, i.uv) * i.hatchWeights1.y;
    fixed4 hatchTex5 = tex2D(_Hatch5, i.uv) * i.hatchWeights1.z;
    fixed4 whiteColor = fixed4(1, 1, 1, 1) * (1 - i.hatchWeights0.x - i.hatchWeights0.y - i.hatchWeights0.z -
    i.hatchWeights1.x - i.hatchWeights1.y - i.hatchWeights1.z);

    fixed4 hatchColor = hatchTex0 + hatchTex1 + hatchTex2 + hatchTex3 + hatchTex4 + hatchTex5 + whiteColor;

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

    return fixed4(hatchColor.rgb * _Color.rgb * atten, 1.0);
    }
    • 当得到了6六张纹理的混合权重后,我们对每张纹理进行采样并和它们对应的权重值相乘得到每张纹理的采样颜色。
    • 我们还计算了纯白在渲染中的贡献度,这是通过从1中减去所有6张纹理的权重来得到的。这是因为素描中往往有留白的部分,因此我们希望在最后的渲染中光照最亮的部分是纯白色的。
    • 最后,我们混合了各个颜色值,并和阴影值 atten、模型颜色_Color相乘后返回最终的渲染结果。
  7. 最后设置合适的FallBack

    1
    FallBack "Diffuse"

13.3 - 拓展阅读

国际讨论会 NPAR(Non-Photorealistic Animation and Rendering)上可以找到许多关于非真实感渲染的论文。

浙江大学的耿卫东教授编纂的书籍《艺术化绘制的图形学原理与方法》(英文名:The Algorithms and Principlesof Non-photorealistic Graphics)。这本书概述了近年来非真实感渲染在各个领域的发展,并简述了许多有重要贡献的算法过程,是一本非常好的参考书籍。

在Unity 的资源商店中,也有许多优秀的非真实感渲染资源。例如,Toon Shader Free(https://www.assetstore.unity3d.com/cn/#!/content/21288)是一个免费的卡通资源包,里面实现了包括轮廓线渲染等卡通风格的渲染。

Toon Styles Shader Pack ( https:/www.assetstore.unity3d.com/cn/#!/content/7212)是一个需要收费的卡通资源包,它包含了更多的卡通风格的 Unity Shader。

Hand-Drawn Shader Pack (https://www.assetstore.unity3d.com/cn/#!/content/12465)同样是一个需要收费的非真实感渲染效果包,它包含了诸如铅笔渲染、蜡笔渲染等多种手绘风格的非真实感渲染效果。

14 - 噪声

14.1 - 消融效果

Shader:Chapter15-Dissolve

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Properties
    {
    _BurnAmount ("Burn Amount", Range(0.0, 1.0)) = 0.0
    _LineWidth("Burn Line Width", Range(0.0, 0.2)) = 0.1
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _BumpMap ("Normal Map", 2D) = "bump" {}
    _BurnFirstColor("Burn First Color", Color) = (1, 0, 0, 1)
    _BurnSecondColor("Burn Second Color", Color) = (1, 0, 0, 1)
    _BurnMap("Burn Map", 2D) = "white"{}
    }

    BurnAmount属性用于控制消融程度,当值为0时,物体为正常效果,当值为1时,物体会完全消融。

    _LineWidth属性用于控制模拟烧焦效果时的线宽,它的值越大,火焰边缘的蔓延范围越广。

    _MainTex_BumpMap分别对应了物体原本的漫反射纹理和法线纹理。

    BurnFirstColorBurnSecondColor对应了火焰边缘的两种颜色值。

    BurnMap则是关键的噪声纹理。

  2. 为了得到正确的光照,设置以下渲染设置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    Cull Off

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_fwdbase

    值得注意的是,我们还使用Cull命令关闭了该Shader的面片剔除,也就是说,模型的正面和背面都会被渲染。这是因为,消融会导致裸露模型内部的构造,如果只渲染正面会出现错误的结果。

  3. 定义顶点着色器

    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
    32
    33
    34
    35
    struct a2v {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float4 texcoord : TEXCOORD0;
    };

    struct v2f {
    float4 pos : SV_POSITION;
    float2 uvMainTex : TEXCOORD0;
    float2 uvBumpMap : TEXCOORD1;
    float2 uvBurnMap : TEXCOORD2;
    float3 lightDir : TEXCOORD3;
    float3 worldPos : TEXCOORD4;
    SHADOW_COORDS(5)
    };


    v2f vert(a2v v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    o.uvMainTex = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.uvBumpMap = TRANSFORM_TEX(v.texcoord, _BumpMap);
    o.uvBurnMap = TRANSFORM_TEX(v.texcoord, _BurnMap);

    TANGENT_SPACE_ROTATION;
    o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;

    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    TRANSFER_SHADOW(o);

    return o;
    }
    • 顶点着色器的代码很常规。我们使用宏TRANSFORM_TEX计算了三张纹理对应的纹理坐标,再把光源方向从模型空间变换到了切线空间。
    • 最后,为了得到阴影信息,计算了世界空间下的顶点位置和阴影纹理的采样坐标(使用了 TRANSFER_SHADOW 宏)。
  4. 定义片元着色器来实现消融效果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    fixed4 frag(v2f i) : SV_Target {
    fixed3 burn = tex2D(_BurnMap, i.uvBurnMap).rgb;

    clip(burn.r - _BurnAmount);

    float3 tangentLightDir = normalize(i.lightDir);
    fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uvBumpMap));

    fixed3 albedo = tex2D(_MainTex, i.uvMainTex).rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));

    fixed t = 1 - smoothstep(0.0, _LineWidth, burn.r - _BurnAmount);
    fixed3 burnColor = lerp(_BurnFirstColor, _BurnSecondColor, t);
    burnColor = pow(burnColor, 5);

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
    fixed3 finalColor = lerp(ambient + diffuse * atten, burnColor, t * step(0.0001, _BurnAmount));

    return fixed4(finalColor, 1);
    }
    • 我们首先对噪声纹理进行采样,并将采样结果和用于控制消融程度的属性 BurnAmount相减,传递给clip函数。当结果小于0时,该像素将会被剔除,从而不会显示到屏幕上。如果通过了测试,则进行正常的光照计算。
    • 根据漫反射纹理得到材质的反射率 albedo,并由此计算得到环境光照,进而得到漫反射光照。
    • 然后,我们计算了烧焦颜色 burnColor
    • 我们想要在宽度为**_LineWidth的范围内模拟一个烧焦的颜色变化,第一步就使用了smoothstep函数来计算混合系数
      t。当t值为1时,表明该像素位于消融的边界处,当t值为0时,表明该像素为正常的模型颜色,而中间的插值则表示需要模拟一个烧焦效果。我们首先用t来混合两种火焰颜色
      _BurnFirstColor**和 BurnSecondColor,为了让效果更接近烧焦的痕迹,我们还使用pow函数对结果进行处理。
    • 然后,我们再次使用t来混合正常的光照颜色(环境光+漫反射)和烧焦颜色。我们这里又使用了step函数来保证当BurnAmount0时,不显示任何消融效果。
    • 最后,返回混合后的颜色值 finalColor
  5. 然后,我们在本例中还定义了一个用于投射阴影的Pass,使用透明度测试的物体的阴影需要特别处理,如果仍然使用普通的
    阴影Pass,那么被剔除的区域仍然会向其他物体投射阴影,造成“穿帮”。为了让物体的阴影也能配合透明度配合透明度
    测试产生正确的效果,我们需要自定义一个投射阴影的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Pass {
    Tags { "LightMode" = "ShadowCaster" }

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_shadowcaster

    顶点着色器的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fixed _BurnAmount;
    sampler2D _BurnMap;
    float4 _BurnMap_ST;

    struct v2f {
    V2F_SHADOW_CASTER;
    float2 uvBurnMap : TEXCOORD1;
    };

    v2f vert(appdata_base v) {
    v2f o;

    TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)

    o.uvBurnMap = TRANSFORM_TEX(v.texcoord, _BurnMap);

    return o;
    }

    片元着色器的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fixed4 frag(v2f i) : SV_Target {
    fixed3 burn = tex2D(_BurnMap, i.uvBurnMap).rgb;

    clip(burn.r - _BurnAmount);

    SHADOW_CASTER_FRAGMENT(i)
    }
    ENDCG
    }
    • 在上面的代码中,我们首先在v2f结构体中利用 V2F_SHADOW_CASTER来定义阴影投射需要定义的变量。
    • 随后,在顶点着色器中,我们使用 TRANSFER_SHADOW_CASTER_NORMALOFFSET 来填充 V2F_SHADOW_CASTER在背后声明的一些变量,这是由Unity在背后为我们完成的。我们需要在顶点着色器中关注自定义的计算部分,这里指的就是我们需要计算噪声纹理的采样坐标uvBurnMap
    • 在片元着色器中,我们首先按之前的处理方法使用噪声纹理的采样结果来剔除片元,最后再利用SHADOW_CASTER_FRAGMENT来让Unity为我们完成阴影投射的部分,把结果输出到深度图和阴影映射纹理中。
  6. 最后设置合适的FallBack

    1
    FallBack "Diffuse"

14.2 - 水波效果

模拟水波效果时,会将噪声作为一张高度图,用来修改水面的法线位置。为了进一步模拟水不断流动的效果,我们使用时间相关变量对噪声纹理进行采样,得到法线信息后再进行反射+折射的计算,最后得到水面波动效果。

Shader:Chapter15-WaterWave

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Properties {
    _Color ("Main Color", Color) = (0, 0.15, 0.115, 1)
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _WaveMap ("Wave Map", 2D) = "bump" {}
    _Cubemap ("Environment Cubemap", Cube) = "_Skybox" {}
    _WaveXSpeed ("Wave Horizontal Speed", Range(-0.1, 0.1)) = 0.01
    _WaveYSpeed ("Wave Vertical Speed", Range(-0.1, 0.1)) = 0.01
    _Distortion ("Distortion", Range(0, 100)) = 10
    }
  2. 为了得到正确的光照,设置以下渲染设置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Pass
    {
    Tags { "LightMode"="ForwardBase" }

    Cull Off

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_fwdbase

    值得注意的是,我们还使用Cull命令关闭了该Shader的面片剔除,也就是说,模型的正面和背面都会被渲染。这是因为,消融会导致裸露模型内部的构造,如果只渲染正面会出现错误的结果。

  3. 定义顶点着色器

    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
    32
    33
    34
    35
    struct a2v {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float4 texcoord : TEXCOORD0;
    };

    struct v2f {
    float4 pos : SV_POSITION;
    float2 uvMainTex : TEXCOORD0;
    float2 uvBumpMap : TEXCOORD1;
    float2 uvBurnMap : TEXCOORD2;
    float3 lightDir : TEXCOORD3;
    float3 worldPos : TEXCOORD4;
    SHADOW_COORDS(5)
    };


    v2f vert(a2v v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    o.uvMainTex = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.uvBumpMap = TRANSFORM_TEX(v.texcoord, _BumpMap);
    o.uvBurnMap = TRANSFORM_TEX(v.texcoord, _BurnMap);

    TANGENT_SPACE_ROTATION;
    o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;

    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

    TRANSFER_SHADOW(o);

    return o;
    }
    • 顶点着色器的代码很常规。我们使用宏TRANSFORM_TEX计算了三张纹理对应的纹理坐标,再把光源方向从模型空间变换到了切线空间。
    • 最后,为了得到阴影信息,计算了世界空间下的顶点位置和阴影纹理的采样坐标(使用了 TRANSFER_SHADOW 宏)。
  4. 定义片元着色器来实现消融效果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    fixed4 frag(v2f i) : SV_Target {
    fixed3 burn = tex2D(_BurnMap, i.uvBurnMap).rgb;

    clip(burn.r - _BurnAmount);

    float3 tangentLightDir = normalize(i.lightDir);
    fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uvBumpMap));

    fixed3 albedo = tex2D(_MainTex, i.uvMainTex).rgb;

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));

    fixed t = 1 - smoothstep(0.0, _LineWidth, burn.r - _BurnAmount);
    fixed3 burnColor = lerp(_BurnFirstColor, _BurnSecondColor, t);
    burnColor = pow(burnColor, 5);

    UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
    fixed3 finalColor = lerp(ambient + diffuse * atten, burnColor, t * step(0.0001, _BurnAmount));

    return fixed4(finalColor, 1);
    }
    • 我们首先对噪声纹理进行采样,并将采样结果和用于控制消融程度的属性 BurnAmount相减,传递给clip函数。当结果小于0时,该像素将会被剔除,从而不会显示到屏幕上。如果通过了测试,则进行正常的光照计算。
    • 根据漫反射纹理得到材质的反射率 albedo,并由此计算得到环境光照,进而得到漫反射光照。
    • 然后,我们计算了烧焦颜色 burnColor
    • 我们想要在宽度为**_LineWidth的范围内模拟一个烧焦的颜色变化,第一步就使用了smoothstep函数来计算混合系数
      t。当t值为1时,表明该像素位于消融的边界处,当t值为0时,表明该像素为正常的模型颜色,而中间的插值则表示需要模拟一个烧焦效果。我们首先用t来混合两种火焰颜色
      _BurnFirstColor**和 BurnSecondColor,为了让效果更接近烧焦的痕迹,我们还使用pow函数对结果进行处理。
    • 然后,我们再次使用t来混合正常的光照颜色(环境光+漫反射)和烧焦颜色。我们这里又使用了step函数来保证当BurnAmount0时,不显示任何消融效果。
    • 最后,返回混合后的颜色值 finalColor
  5. 然后,我们在本例中还定义了一个用于投射阴影的Pass,使用透明度测试的物体的阴影需要特别处理,如果仍然使用普通的
    阴影Pass,那么被剔除的区域仍然会向其他物体投射阴影,造成“穿帮”。为了让物体的阴影也能配合透明度配合透明度
    测试产生正确的效果,我们需要自定义一个投射阴影的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Pass {
    Tags { "LightMode" = "ShadowCaster" }

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    #pragma multi_compile_shadowcaster

    顶点着色器的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fixed _BurnAmount;
    sampler2D _BurnMap;
    float4 _BurnMap_ST;

    struct v2f {
    V2F_SHADOW_CASTER;
    float2 uvBurnMap : TEXCOORD1;
    };

    v2f vert(appdata_base v) {
    v2f o;

    TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)

    o.uvBurnMap = TRANSFORM_TEX(v.texcoord, _BurnMap);

    return o;
    }

    片元着色器的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fixed4 frag(v2f i) : SV_Target {
    fixed3 burn = tex2D(_BurnMap, i.uvBurnMap).rgb;

    clip(burn.r - _BurnAmount);

    SHADOW_CASTER_FRAGMENT(i)
    }
    ENDCG
    }
    • 在上面的代码中,我们首先在v2f结构体中利用 V2F_SHADOW_CASTER来定义阴影投射需要定义的变量。
    • 随后,在顶点着色器中,我们使用 TRANSFER_SHADOW_CASTER_NORMALOFFSET 来填充 V2F_SHADOW_CASTER在背后声明的一些变量,这是由Unity在背后为我们完成的。我们需要在顶点着色器中关注自定义的计算部分,这里指的就是我们需要计算噪声纹理的采样坐标uvBurnMap
    • 在片元着色器中,我们首先按之前的处理方法使用噪声纹理的采样结果来剔除片元,最后再利用SHADOW_CASTER_FRAGMENT来让Unity为我们完成阴影投射的部分,把结果输出到深度图和阴影映射纹理中。
  6. 最后设置合适的FallBack

    1
    FallBack "Diffuse"

14.3 - 再谈全局雾效

先前实现过的基于屏幕后处理的全局雾效。是由深纹理重建每个像素在世界空间下的位置,再使用一个基于高度的公式来计算雾效的混合系数,最后使用该系数来混合雾的颜色和原屏幕颜色。

在同一个高度上,雾的浓度是相同的。然而,一些时候我们希望可以模拟种不均匀的雾效,同时让雾不断飘动,使雾看起来更加飘渺,而这就可以i过使用一张噪声纹理来实现。

Script:FogWithNoise

  1. 继承基类:PostEffectsBase

  2. 声明该效果需要的Shader,并创建相应的材质

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public Shader FogWithNoiseShader = null;
    private Material FogWithNoiseMaterial = null;

    public Material material
    {
    get
    {
    FogWithNoiseMaterial = CheckShaderAndCreateMaterial(FogWithNoiseShader, FogWithNoiseMaterial);
    return FogWithNoiseMaterial;
    }
    }
  3. 在本节中,我们需要获取摄像机的相关参数,如近裁剪平面的距离FOV等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向,因此我们用两个变量存储摄像机的Camera组件Transform 组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    private Camera myCamera;
    public Camera camera
    {
    get {
    if (myCamera == null) {
    myCamera = GetComponent<Camera>();
    }
    return myCamera;
    }
    }

    private Transform myCameraTransform;
    public Transform cameraTransform {
    get {
    if (myCameraTransform == null) {
    myCameraTransform = camera.transform;
    }
    return myCameraTransform;
    }
    }
  4. 定义模拟雾效时使用的各个参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    [Range(0.1f, 3.0f)]
    public float fogDensity = 1.0f;

    public Color fogColor = Color.white;

    public float fogStart = 0.0f;
    public float fogEnd = 2.0f;

    public Texture noiseTexture;

    [Range(-0.5f, 0.5f)]
    public float fogXSpeed = 0.1f;

    [Range(-0.5f, 0.5f)]
    public float fogYSpeed = 0.1f;

    [Range(0.0f, 3.0f)]
    public float noiseAmount = 1.0f;

    • fogDensity 用于控制雾的浓度,fogColor用于控制雾的颜色。
    • 我们使用的雾效模拟函数是基于高度的,因此参数fogStart用于控制雾效的起始高度fogEnd用于控制雾效的终止高度
    • noiseTexture是我们使用的噪声纹理,fogXSpeedfogYSpeed分别表示了噪声纹理在X和Y方向上的移动速度,以此来模拟雾的飘动效果。
    • 最后,noiseAmount用于控制噪声程度,当noiseAmount为0时,代表不应用任何噪声。
  5. 本例需要获取摄像机的深度纹理,在OnEnable中设置摄像机的相应状态。

    1
    2
    3
    4
    private void OnEnable()
    {
    camera.depthTextureMode |= DepthTextureMode.Depth;
    }
  6. 定义OnRenderImage来进行特效处理

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    void OnRenderImage (RenderTexture src, RenderTexture dest) {
    if (material != null) {
    Matrix4x4 frustumCorners = Matrix4x4.identity;

    float fov = camera.fieldOfView;
    float near = camera.nearClipPlane;
    float aspect = camera.aspect;

    float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
    Vector3 toRight = cameraTransform.right * halfHeight * aspect;
    Vector3 toTop = cameraTransform.up * halfHeight;

    Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
    float scale = topLeft.magnitude / near;

    topLeft.Normalize();
    topLeft *= scale;

    Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
    topRight.Normalize();
    topRight *= scale;

    Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
    bottomLeft.Normalize();
    bottomLeft *= scale;

    Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
    bottomRight.Normalize();
    bottomRight *= scale;

    frustumCorners.SetRow(0, bottomLeft);
    frustumCorners.SetRow(1, bottomRight);
    frustumCorners.SetRow(2, topRight);
    frustumCorners.SetRow(3, topLeft);

    material.SetMatrix("_FrustumCornersRay", frustumCorners);

    material.SetFloat("_FogDensity", fogDensity);
    material.SetColor("_FogColor", fogColor);
    material.SetFloat("_FogStart", fogStart);
    material.SetFloat("_FogEnd", fogEnd);

    material.SetTexture("_NoiseTex", noiseTexture);
    material.SetFloat("_FogXSpeed", fogXSpeed);
    material.SetFloat("_FogYSpeed", fogYSpeed);
    material.SetFloat("_NoiseAmount", noiseAmount);

    Graphics.Blit (src, dest, material);
    } else {
    Graphics.Blit(src, dest);
    }
    }
    • OnRenderImage首先计算了近裁剪平面的四个角对应的向量,并把它们存储在一个矩阵类型的变量(frustumCormers)中。
    • 我们按一定顺序把这四个方向存储到了frustumCorners不同的行中,这个顺序是非常重要的,因为这决定了我们在顶点着色器中使用哪行作为该点的待插值向量。
    • 随后,我们把结果和其他参数传递给材质,并调用Graphics.Blit (src, dest, material)把渲染结果显示在屏幕上。

Shader:Chapter15-FogWithNoise

  1. 声明以下属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Properties {
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _FogDensity ("Fog Density", Float) = 1.0
    _FogColor ("Fog Color", Color) = (1, 1, 1, 1)
    _FogStart ("Fog Start", Float) = 0.0
    _FogEnd ("Fog End", Float) = 1.0
    _NoiseTex ("Noise Texture", 2D) = "white" {}
    _FogXSpeed ("Fog Horizontal Speed", Float) = 0.1
    _FogYSpeed ("Fog Vertical Speed", Float) = 0.1
    _NoiseAmount ("Noise Amount", Float) = 1
    }

    其中,**_Sensitivity**的xy分量分别对应了法线和深度的检测灵敏度,zw分量则没有实际用途。

  2. 声明对应变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    float4x4 _FrustumCornersRay;

    sampler2D _MainTex;
    half4 _MainTex_TexelSize;
    sampler2D _CameraDepthTexture;
    half _FogDensity;
    fixed4 _FogColor;
    float _FogStart;
    float _FogEnd;
    sampler2D _NoiseTex;
    half _FogXSpeed;
    half _FogYSpeed;
    half _NoiseAmount;

    FrustumCornersRay虽然没有在 Properties中声明,但仍可由脚本传递给Shader。

    除了在上面的代码中,我们还声明了需要获取的深度纹理**_CameraDepthNormalsTexture**。

  3. 顶点着色器与先前的完全一致

  4. 定义片元着色器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    fixed4 frag(v2f i) : SV_Target {
    float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
    float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;

    float2 speed = _Time.y * float2(_FogXSpeed, _FogYSpeed);
    float noise = (tex2D(_NoiseTex, i.uv + speed).r - 0.5) * _NoiseAmount;

    float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
    fogDensity = saturate(fogDensity * _FogDensity * (1 + noise));

    fixed4 finalColor = tex2D(_MainTex, i.uv);
    finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);

    return finalColor;
    }
    • 我们首先根据深度纹理来重建该像素在世界空间中的位置。
    • 然后,我们利用内置的**_Time变量和FogXSpeedFogYSpeed属性计算出当前噪声纹理的偏移量,并据此对噪声纹理进行采样,得到噪声值。我们把该值减去 0.5,再乘以控制噪声程度的属性_NoiseAmount**,得到最终的噪声值。
    • 随后,我们把该噪声值添加到雾效浓度的计算中,得到应用噪声后的雾效混合系数fogDensity
    • 最后,我们使用该系数将雾的颜色和原始颜色进行混合后返回
  5. 然后,我们定义雾效渲染需要用到的Pass

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass {
    ZTest Always Cull Off ZWrite Off

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    ENDCG
    }
  6. 最后关闭FallBack

    1
    Fallback Off

15 - Unity 中的渲染优化技术

在本章,我们将会阐述一些Unity中常见的优化技术。这些优化技术都是和渲染相关的,例如,使用批处理LOD (Level of Detail)技术等

游戏优化不仅是程序员的工作,更需要美工人员在游戏的美术上进行一定的权衡,

例如,避免使用全屏的屏幕特效避免使用计算复杂的shader减少透明混合造成的overdraw等。也就是说,这是由程序员和美工人员等各个部分人员共同参的工作。

15.1 - 移动平台的特点

为了尽可能移除那些隐藏的表面,减少**overdraw(即一个像素被绘制多次)**,PowerV芯片(通常用于iOS 设备和某些Android设备)使用了基于瓦片的延迟渲染(Tiled-based Deferrred Rendering,TBDR)架构,把所有的渲染图像装入一个个瓦片(tile)中,再由硬件找到可见的片元,而只有这些可见片元才会执行片元着色器。

另一些基于瓦片的GPU架构,如 Adreno高通的芯片)和 Mali (ARM的芯片)则会使用Early-Z或相似的技术进行一个低精度的的深度检测来剔除那些不需要渲染的片元,

还有一些 GPU,如 Tegra(英伟达的芯片),则使用了传统的架构设计,因此在这些设备上,overdraw 更可能造成性能的瓶颈。

相比与Android 平台,iOS平台硬件条件则相对统一。读者可以在 Unity手册的 iOS硬件指南( http:/docs.unity3d.com/Manua.iphone-Hardware.html)中找到相关的资料。

15.2 - 影响性能的因素

对于一个游戏来说,他主要需要使用两种计算资源:CPU主要负责保证帧率,GPU主要负责分辨率相关的一些处理。

造成游戏性能瓶颈的主要原因分以下几类:

CPU:

  • 过多的draw call。
  • 复杂的脚本或者物理模拟。

GPU:

  • 顶点处理
    • 过多的顶点。
    • 过多的逐顶点计算。
  • 片元处理
    • 过多的片元(既可能是由于分辨率造成的,也可能是由于overdraw造成的)。
    • 过多的逐片元计算。

带宽:

  • 使用了尺寸很大且未压缩的纹理。
  • 分辨率过高的帧缓存。

对于 CPU 来说,限制它的主要是每一帧中 draw call的数目

当然,其他原因也可能造成CPU瓶颈,例物理、布料模拟、蒙皮、粒子模拟等,这些都是计算量很大的操作。

而对于GPU 来说,它负责整个渲染流水线。它从处理 CPU 传递过来的模型数据开始,进行顶点着色器、片元着色器等一系列工作,最后输出屏幕上的每个像素。因此,GPU的性能瓶颈和需要处理的顶点数目屏幕分辨率显存等因素有关。

而相关的优化策略可以从减少处理的数据规模(包括顶点数目和片元数目)减少运算复杂度等方面入手。

本章涉及到的优化技术有:

CPU优化:

  • 使用批处理技术减少draw call数目

GPU优化:

  • 减少需要处理的顶点数目
    • 优化几何体
    • 使用模型的LOD(Level of Detail)技术
    • 使用遮挡剔除(Occlusion Culling)技术
  • 减少需要处理的片元数目
    • 控制绘制顺序
    • 警惕透明物体
    • 减少实时光照
  • 减少计算复杂度
    • 使用Shader的LOD(Level of Detail)技术
    • 代码方面的优化

节省内存带宽:

  • 减少纹理大小
  • 使用分辨率缩放

15.3 - Unity中的渲染分析工具

Unity中的渲染分析工具包括:

  • 渲染统计窗口(Rendering Statistics Window)
  • 性能分析器(Profiler)
  • 帧调试器(Frame Debugger)

15.3.1 - 渲染统计窗口

在Game窗口右上方菜单栏可以看到。渲染统计窗口中显示了很多重要的渲染数据,例如FPS批处理数目顶点和三角网格的数目等。下表列出了渲染统计窗口中显示的各个信息。

信息名称 描述
每帧的时间和 FPS 在 Graphic的右侧显示,给出了处理和渲染一帧所需的时间,以及FPS 数目
Batches 一帧中需要进行的批处理数目
Saved by batching 合并的批处理数目,这个数字表明了批处理为我们节省了多少draw call
Tris 和 Verts 需要绘制的三角面片和顶点数目
Screen 屏幕的大小,以及它占用的内存大小
SetPass 渲染使用的Pass的数目,每个Pass都需要Unity的runtime来绑定一个新的 Shader,这可能成 CPU的瓶颈
Visible Skinned Meshes 渲染的蒙皮网格的数目
Animations 播放的动画数目

15.3.2 - 性能分析器

我们可以通过单击Window -> Profiler来打开 Unity 的性能分析器(Profiler)。

性能分析器显示了绝大部分在渲染统计窗口中提供的信息,例如,绿线显示了批处理数目、蓝线显示了Pass数目等,同时还给出了许多其他非常有用的信息,例如,draw call 数目、动态批处理/静态批处理的数目、渲染纹理的数目和内存占用等。

15.3.3 - 帧调试器

我们可以通过Window -> Frame Debugger来打开

15.3.4 - 其他性能分析工具

  • 对于Android平台来说,高通的Adreno分析工具可以对不同的测试机进行详细的性能分析。英伟达提供了NVPerfHUD 工具来帮助我们得到几乎所有需要的性能分析数据,例如,每个draw call的 GPU 时间,每个shader花费的cycle数目等。
  • 对于 iOS平台来说,Unity内置的分析器可以得到整个场景花费的GPU时间。PowerVRAM的PVRUniSCo shader 分析器也可以给出一个大致的性能评估。Xcode 中的OpenGL ES Driver Instruments可以给出一些宏观上的性能信息,例如,设备利用率、渲染器利用率等。但相对于Android平台,对iOS 的性能分析更加困难(工具较少)。而且 PowerVR芯片采用了基于瓦片的延迟渲染器,因此,想要得到每个draw call 花费的GPU时间是几乎不可能的。这时,一些宏观上的统计数据可能更有参考价值。

一些其他的性能分析工具可以在Unit的官方手册(http://docs.unity3d.com/Manual/MobileProfiling.html)中找到。当找到了性能瓶颈后,我们就可以针对这些方面进行特定的优化

15.4 - 减少Draw Call 数目

最常见的优化技术大概就是批处理(batching)了。批处理的实现原理就是为了减每一帧需要的draw call数目,为此在每次面对draw call时尽可能多地处理多个物体。

一个极端的例子是,如果我们需要渲染一千个三角形,把它们按一千个单的网格进行渲染所花费的时间要远远大于渲染一个包含了一千个三角形的网格。在这两种情况下GPU 的性能消耗其实并没有多大的区别,但 CPU的draw call数目就会成为性能瓶颈。

使用同一个材质的物体可以一起处理。这是因为,对于用同一个材质的物体,它们之间的不同仅仅在于顶点数据的差别。我们可以把这些顶点数据合在一起,再一起发送给GPU,就可以完成一次批处理。

Unity 中支持两种批处理方式:一种是动态批处理,另一种是静态批处理

  • 对于动态批处理来说,优点是一切处理都是Unity自动完成的,不需要我们自己做任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就会破坏了这种机制,导致 Unity 无法动态批处理些使用了相同材质的物体。
  • 而对于静态批处理来说,它的优点是自由度很高,限制很少;但缺点是可能会占用更多的内存,而且经过静态批处理后的所有物体都不可以再移动了(即便在脚本中尝试改变物体的位置也是无效的)。

15.4.1 - 动态批处理

动态批处理的基本原理是,每一帧把可以进行批处理的模型网格进行合并,再把合并后模型数据传递给GPU,然后使用同一个材质对其渲染。除了实现方便,动态批处理的另一个好处是,经过批处理的物体仍然可以移动,这是由于在处理每帧时Unity都会重新合并一次网格。

但是动态批处理有以下限制条件:

  • 能够进行动态批处理的网格的顶点属性规模要小于900。例如,如果shader中需要使用顶点位置、法线和纹理坐标这3个顶点属性,那么要想让模型能够被动态批处理,它的顶点数目不能超过300。需要注意的是,这个数字在未来有可能会发生变化,因此不要依赖这个数据。

  • 使用光照纹理(lightmap)的物体需要小心处理。这些物体需要额外的渲染参数,例如,在光照纹理上的索引、偏移量和缩放信息等。因此,为了让这些物体可以被动态批处理,我们需要保证它们指向光照纹理中的同一个位置

  • 多Pass 的shader会中断批处理。在前向渲染中,我们有时需要使用额外的Pass来为模型添加更多的光照效果,但这样一来模型就不会被动态批处理了。

    如果我们在只有平行光的场景下添加一个点光源,基于向前渲染的物体,即使用了多个Pass 的 shader在需要应用多个光照的情况下,破坏了动态批处理的机制,导致 Unity不能对这些物体进行动态批处理。

15.4.2 - 静态批处理

静态批处理的实现原理是,只在运行开始阶段,把需要进行静态批处理的模型合并一个新的网格结构中。

这意味着这些模型不可以在运行时刻被移动。但由于它只需要进行一次合并操作,因此,比动态批处理更加高效。

静态批处理的另一个缺点在于,它往往需要占用更多的内存来存储合并后的几何结构。这是因为,如果在静态批处理前一些物体共享了相同的网格,那么在内存中每一个物体都会对应一个该网格的复制品,即一个网格会变成多个网格再发送给GPU。如果这类使用同一网格的对象很多,那么这就会成为一个性能瓶颈了。

静态批处理的实现非常简单,只需要把物体面板上的Static复选框勾选上即可(实际上我们只需要勾选 Batching Static即可)

在内部实现上,Unity首先把这些静态物体变换到世界空间下,然后为它们构建一个更大的顶点和索引缓存。

  • 对于使用了同一材质的物体,Unity 只需要调用一个draw call就可以绘制全部物体。
  • 而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能。尽管这些物体仍然需要调用多个draw call,但静态批处理可以减少这draw call之间的状态切换,而这些切换往往是费时的操作。从合并后的网格结构中我们还可以发现,尽管3个 Teapot对象使用了同一个网格,

如果场景中包含了除了平行光以外的其他光源,并且在 shader 中定义了额外的Pass来处理它们,这些额外的Pass 部分是不会被批处理的。但是,处理平行光的Base Pass部分仍然会被静态批处理。因此,我们仍然可以节省Draw Call。

15.4.3 - 共享材质

无论是动态批处理还是静态批处理,都要求模型之间需要共享同个材质。但不同的模型之间总会需要有不同的渲染属性,例如,使用不同的纹理、颜色等。这时我们需要一些策略来尽可能地合并材质。

  • 如果两个材质之间只有使用的纹理不同,我们可以把这些纹理合并到一张更大的纹理中,张更大的纹理被称为是一张图集(atlas)。一旦使用了用一张纹理,我们可以使用同一个材质,在使用不同的采样坐标对纹理采样即可。
  • 但有时,除了纹理不同外,不同的物体在材质上还有一些微小的参数变化,例如,颜色不同、某些浮点属性不同。一种常用的方法就是使用网格的顶点数据(最常见的就是顶点颜色数据)来存储这些参数。

前面说过,经过批处理后的物体会被处理成更大的**VBO(Vertex Buffer Object,顶点缓冲对象)**发送给 GPU,VBO中的数据可以为输入传递给顶点着色器,因此,我们可以巧妙地对 VBO 中的数据进行控制,从而达到不同效果的目的。一个例子是,森林场景中所有的树使用了同一种材质,我们希望它们可以通过批处理来减少draw call,但不同树的颜色可能不同。这时,我们可以利用网格的顶点的颜色数据来调整。

15.4.4 - 批处理的注意事项

  • 尽可能选择静态批处理,但得时刻小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动。
  • 如果无法进行静态批处理,而要使用动态批处理的话,那么请小心上面提到的各种条件限制。例如,尽可能让这样的物体少并且尽可能让这些物体包含少量的顶点属性和顶点数目。
  • 对于游戏中的小道具,例如可以捡拾的金币等,可以使用动态批处理。
  • 对于包含动画的这类物体,我们无法全部使用静态批处理,但其中如果有不动的部分,可以把这部分标识成“Static”。

除了上述提示外,在使用批处理时还有一些需要注意的地方。由于批处理需要把多个模型变换到世界空间下再合并它们,因此,如果shader中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。一个解决方法是,在 shader中使DisableBatching标签来强制使用该 Shader的材质不会被批处理。

另一个注意事项是,使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性。对于这些物体,Unity 会首先保证它们的绘制顺序,再尝试对它们进行批处理。这意味着,当绘制顺序无法满足时,批处理无法在这些物体上被成功应用。

15.5 - 减少需要处理的顶点数目

15.5.1 - 优化几何体

在GPU看来,有时需要把一个顶点拆分成两个或更多的顶点。这种将顶点一分为多的原因主要有两个:一个是为了**分离纹理坐标(uv splits),另一个是为了产生平滑的边界(smoothingsplits)**。它们的本质,其实都是因为对于GPU来说,顶点的每一个属性和顶点之间必须是一对一的关系。而分离纹理坐标,是因为建模时一个顶点的纹理坐标有多个。例如,对于一个立方体,它的6个面之间虽然使用了一些相同的顶点,但在不同面上,同一个顶点的纹理坐标可能并不相同。对于GPU来说,这是不可理解的,因此,它必须把这个顶点拆分成多个具有不同纹理坐标的顶点。而平滑边界也是类似的,不同的是,此时这个顶点可能会对应多个法线信息或切线信息。
这通常是因为我们要决定一个边是一条硬边(hard edge)还是一条平滑边(smooth edge)。

对于GPU来说,它本质上只关心有多少个顶点。因此,尽可能减少顶点的数目其实才是我们真正需要关心的事情。因此,最后一条几何体优化建议就是:移除不必要的硬边以及纹理衔接,避免边界平滑和纹理分离。

15.5.2 - 模型的LOD技术

另一个减少顶点数目的方法是使用LOD(Level of Detail)技术。这种技术的原理是,当一个物体离摄像机很远时,模型上的很多细节是无法被察觉到的。因此,LOD 允许当对象逐渐远离摄像机时,减少模型上的面片数量,从而提高性能。

在Unity中,我们可以使用LOD Group组件来为一个物体构建一个LOD。我们需要为同一个对象准备多个包含不同细节程序的模型,然后把它们赋给LOD Group组件中的不同等级,Unity就会自动判断当前位置上需要使用哪个等级的模

15.5.1 - 遮挡剔除技术

我们最后要介绍的顶点优化策略就是遮挡剔除(Occlusion culling)技术。遮挡剔除可以用来消除那些在其他物件后面看不到的物件,这意味着资源不会浪费在计算那些看不到的顶点上,进而提升性能。

我们需要把遮挡剔除摄像机的视锥体剔除(Frustum Culling)区分开来。

  • 视锥体剔除只会剔除掉那些不在摄像机的视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住。
  • 而遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在可见的对象集合的层级结构。在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而哪些被其他物体挡住不可见。

使用遮挡剔除技术,不仅可以减少处理的顶点数目,还可以减少overdraw,提高游戏性能。

要在 Unity 中使用遮挡剔除技术,我们需要进行一系列额外的处理工作。具体步骤可以参见Unity手册的相关内容(http://ldocs.unity3d.com/ManualOcclusionCulling.html)
模型的LOD技术和遮挡剔除技术可以同时减少CPU和 GPU的负荷。CPU可以提交更少的draw call,而 GPU需要处理的顶点和片元数目也减少了。

15.6 - 减少需要处理的片元数目

另一个造成 GPU瓶颈的是需要处理过多的片元。这部分优化的重点在于减少ovrerdraw。简单来说,overdraw指的就是同一个像素被绘制了多次。

15.6.1 - 控制绘制顺序

为了最大限度地避免overdraw,重要的优化策略就是控制绘制顺序。由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么就可以很大程度上减少overdraw。这是因为,在后面绘制的物体由于无法通过深度测试,因此,就不会再进行后面的渲染处理。

在Unity中,那些渲染队列数目小于2500(如“Background””Geometry”和“AlphaTest” 的对象都被认为是不透明(opaque)的物
这些物体总体上是从前往后绘制的,而队列(如“Transparent””Overlay”等的物体,则是从后往前绘制的。这意味着,我们可以尽可能地把物体的队列设置为不透明物体的渲染队列,而尽量避免使用半透明队列。

而且,我们还可以充分利用 Unity 的渲染队列来控制绘制顺序。例如,对于游戏中的主要人物角色来说,他们使用的shader 往往比较复杂,但是,由于他们通常会挡住屏幕的很大一部分区域,因此我们可以先绘制它们(使用更小的渲染队列)。

而对于一些敌方角色,它们通常会出现在各种掩体后面,因此,我们可以在所有常规的不透明物体后面渲染它们(使用更大的渲染队列)。而对于天空盒子来说,它几乎覆盖了所有的像素,而且我们知道它永远会出现在所有物体的后面,因此,它的队列可以设置为“Geometry+1”。这样,就可以保证不会因为它而造成overdraw。

15.6.2 - 时刻警惕透明物体

对于半透明对象来说,由于它们没有开启深度写入,因此,如果要得到正确的渲染效果,就必须从后往前渲染。这意味着,半透明物体几乎一定会造成 overdraw.如果我们不注意这一点,在一些机器上可能会造成严重的性能下降。例如,对于GUI对象来说,它们大多被设置成了半透明,如果屏幕中GUI占据的比例太多,而主摄像机又没有进行调整而是投影整个屏幕,那么GUI就会造成大量overdraw 。

对于上述GUI的这种情况,我们可以尽量减少窗口中GUI所占的面积。如果实在无能为力,我们可以把GUI的绘制和三维场景的绘制交给不同的摄像机,而其中负责三维场景的摄像机的视角范围尽量不要和 GUI的相互重叠。

在移动平台上,透明度测试也会影响游戏性能。虽然透明度测试没有关闭深度测试,但由于它的实现使用了discard 或 clip操作,而这些操作会导致一些硬件的优化策略失效。这种时候,使用透明度混合的性能往往比使用透明度测试更好。

15.6.3 - 减少实时光照和阴影

这些游戏的画面效果看起来好像包含了很多光源,但其实这都是骗人的,往往使用了烘焙技术,把光照提前烘焙到一张**光照纹理(lightmap)**中,然后在运行时刻只需要根据纹理采样得到光照结果即可。

另一个模拟光源的方法是使用 God Ray。场景中往很多小型光源的效果都是靠这种方法模拟的。它们一般并不是真的光源,很多情况是通过透明纹理模拟得到的。

在游戏《ShadowGun》中,游戏角色看起来使用了非常复杂高级的光!照计算,但这实际上是优化后的结果。开发者们把复杂的光照计算存储到一张查找纹理(lookup texture,也被称为查找表,lookup table,LUT) 中。然后在运行时刻,我们只需要使用光源方向、视角方向、法线方向等参数,对LUT 采样得到光照结果即可。使用这样的查找纹理,不仅可以让我们使用更出色的光照模型,例如,更加复杂的 BRDF模型,还可以利用查找纹理的大小来进一步优化性能,例如,主要角色可以使用更大分辨率的LUT,而一些NPC就使用较小的LUT。《ShadowGun》的开发者还开发了一个LUT 烘焙工具,来帮助美工人员快速调整光照模型,并把结果存储到LUT 中。

15.7 - 节省带宽

大量使用未经压缩的纹理以及使用过大的分辨率都会造成由于带宽而引发的性能瓶颈。

15.7.1 - 减少纹理大小

之前提到过,使用纹理图集可以帮助我们减少draw call的数目,而这些纹理的大小同样是个需要考虑的问题。需要注意的是,所有纹理的长宽比最好是正方形,而且长宽值最好是2的整数幂。这是因为有很多优化策略只有在这种时候才可以发挥最大效用。

除此之外,我们还应该尽可能使用多级渐远纹理技术(mipmapping)纹理压缩

15.7.2 - 利用分辨率缩放

在 Unity 中设置屏幕分辨率可以直接调用Screen.SetResolution。

实际使用中可能会遇到一些情况,雨松MOMO有一篇文章(http://www.xuanyusong.com/archives/3205)详细讲解了如何使用这种技术,读者可参考。

15.8 - 减少计算复杂度

15.8.1 - Shader的LOD技术

它的原理是,只有Shader的LOD值小于某个设定的值,这个Shader 才会被使用,而使用了那些超过设定值的Shader 的物体将不会被渲染。

我们也可以在Unity Shader的导入面板上看到该Shader使用的LOD值。在默认情况下,允许的LOD等级是无限大的。这意味着,任何被当前显卡支持的Shader都可以被使用。但是,在某些情况下我们可能需要去掉一些使用了复杂计算的Shader渲染。这时,我们可以使用Shader.maximumLODShader.globalMaximumLOD来设置允许的最大LOD值。
Unity内置的Shader使用了不同的LOD值,例如,Diffuse 的 LOD 为200,而 Bumped Specular的LOD为400。

15.8.2 - 代码方面的优化

在实现游戏效果时,我们可以选择在哪里进行某些特定的运算。通常来讲,游戏需要计算的对象、顶点和像素的数目排序是对象数<顶点数像素数。因此,我们应该尽可能地把计算放在每个对象或逐顶点上。

首先第一点是,尽可能使用低精度的浮点值进行运算。

  • 最高精度的float/highp适用于存储诸如顶点坐标等变量,但它的计算速度是最慢的,我们应该尽量避免在片元着色器中使用这种精度进行计算。
  • half/mediump适用于一些标量、纹理坐标等变量
  • fixed/lowp适用于绝大多数颜色变量和归一化后的方向矢量,在进行一些对精度要求不高的计算时,我们应该尽量使用这种精度的变量。

要避免对这些低精度变量进行频繁的swizzle操作(如color.xwxW)。
还需要注意的是,我们应当尽量避免在不同精度之间的转换,这有可能会造成一定的性能下降。

对于绝大多数GPU来说,在使用插值寄存器把数据从顶点着色器传递给下一个阶段时,我们应该使用尽可能少的插值变量。例如,如果需要对两个纹理坐标进行插值,我们通常会把它们打包在同一个float4类型的变量中,两个纹理坐标分别对应了xy分量和zw分量。然而,对于PowerVR平台来说,这种插值变量是非常廉价的,直接把不同的纹理坐标存储在不同的插值变量中,有时反而性能更好。尤其是,如果在PowerVR上使用类似tex2D(_MainTex,uv.zw)这样的语句来进行纹理采样,GPU就无法进行一些纹理的预读取,因为它会认为这些纹理采样是需要依赖其他数据的。因此,如果我们特别关心游戏在PowerVR 上的性能,就不应该把两个纹理坐标打包在同一个四维变量中。

尽可能不要使用全屏的屏幕后处理效果。如果美术风格实在是需要使用类似 Bloom热扰动这样的屏幕特效,我们应该尽量使用fixed/lowp进行低精度运算(纹理坐标除外,可以使用half/mediump)。

那些高精度的运算可以使用查找表(LUT)或者转移到顶点着色器中进行处理。除此之外,尽量把多个特效合并到一个 Shader中。例如,我们可以把颜色校正和添加噪声等屏幕特效在Bloom特效的最后一个Pass中进行合成。还有一个方法就是使用16.8.3节中介绍的缩放思想,来选择性地开启特效。

还有一些常见的代码优化规则。

  • 尽可能不要使用分支语句和循环语句。
  • 尽可能避免使用类似sin、tan、pow、log等较为复杂的数学运算。我们可以使用查找表来作为替代。
  • 尽可能不要使用 discard操作,因为这会影响硬件的某些优化。

15.8.3 - 根据硬件条件进行缩放

如何确保游戏可以同时流畅地运行在不同性能的移动设备上呢?一个非常简单且实用的方式是使用所谓的放缩(scaling)思想。

我们首先保证游戏最基本的配置可以在所有的平台上运行良好,而对于一些具有更高表现能力的设备,我们可以开启一些更“养眼”的效果,比如使用更高的分辨率,开启屏幕后处理特效,开启粒子效果等。

15.9 - 拓展阅读

Unity 官方手册的移动平台优化实践指南(http://ldocs.unity3d.com/Manual/MobileOptimizationPracticalGuide.html)一文给出了一些针对移动平台的优化技术,包括渲染和图形方面的优化,以及脚本优化等。

手册中另一个针对图像性能优化的文档是优化图像性能(http://docs.unity3d.com/Manual/OptimizingGraphicsPerformance.html)一文,在这个文档中,Unity给出了常见的性能瓶颈以及一些相应的优化技术。除此之外,文档列出了一个清单,包含了优化游戏性能的常见做法和约束。

在SIGGRAPH 2011上,Unity进行了一个关于移动平台上 Shader 优化的演讲(http://blogs.unity3d.com/2011/08/18/fast-mobile-shaders-talk-at-siggraph/)。在这个演讲中,作者给出了各个主流移动GPU的架构特点,并给出了相应的shader优化细节,还结合了真实的Unity游戏项目来进行实例学习。

在Unite 2013 会议上,Unity呈现了一个名为针对移动平台优化Unity游戏的演讲,在这个简短的演讲中,作者对造成性能瓶颈的原因进行了分类,并给出了一些常见的优化技术。

在GDC 2014 上,Unity 展示了如何使用内置的分析器分析移动平台的游戏性能,读者可以在Youtube上找到相应的视频。

在SIGGRAPH 2015会议上,Unity进行了一系列演讲和课程。

在Uni和来自高通、ARM等公司的开发人员共同呈现的名为Moving Mobile Graphics 的课程中,来Unity的 Renaldas Zioma 讲解了移动平台上PBR的优化技术。更多Unity在 SIGGRAPH 2015上的演讲,读者可以参见Unity的博客。

除了手册和演讲资料外,成功的移动平台中的游戏同样是非常好的学习资料。《ShadowGun)是由 MadFinger在2011年发布的一款移动平台的第三人称射击游戏,使用的开发工具正是 Unity在Unite 2011上,该游戏的开发者给出了《ShadowGun》中使用的渲染和优化技术,读者可以在Youtube上面找到这个视频。

更难能可贵的是,在2012年,《ShadowGun》的开发者放出了示例场景,来让更多的开发者学习如何优化移动平台上的 shader。

另一个非常好的游戏优化实例是Unity自带的项目《Angry Bots》,读者可以直接在Unity资源商店下载到完整的项目源代码。

16 - Unity 表面着色器实现

Aras认为,渲染流程的抽象应该划分成表面着色器光照模型光照着色器这样的层面其中,表面着色器定义了模型表面的反射率、法线和高光等,光照模型则选择是使用兰伯特还是Blinn-Phong等模型。而光照着色器负责计算光照衰减、阴影等。这样,绝大部分时间我们只需要利表面着色器打交道,例如,混合纹理和颜色等。光照模型可以是提前定义好的,我们只需要选择叨种预定义的光照模型即可。而光照着色器一旦由系统实现后,更不会被轻易改动,从而大大减轻了Shader编写者的工作量。

表面着色器本质上就是包含了很多Pass的顶点/片元着色器。

16.1 - 表面着色器的一个例子

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
32
33
34
35
Shader "Unity Shaders Book/Chapter 17/Bumped Diffuse" {
Properties {
_Color ("Main Color", Color) = (1,1,1,1)
_MainTex ("Base (RGB)", 2D) = "white" {}
_BumpMap ("Normalmap", 2D) = "bump" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 300

CGPROGRAM
#pragma surface surf Lambert
#pragma target 3.0

sampler2D _MainTex;
sampler2D _BumpMap;
fixed4 _Color;

struct Input {
float2 uv_MainTex;
float2 uv_BumpMap;
};

void surf (Input IN, inout SurfaceOutput o) {
fixed4 tex = tex2D(_MainTex, IN.uv_MainTex);
o.Albedo = tex.rgb * _Color.rgb;
o.Alpha = tex.a * _Color.a;
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
}

ENDCG
}

FallBack "Legacy Shaders/Diffuse"
}

我们可以轻松地实现常见的光照模型,甚至不需要和任何光照变量打交道。

表面着色器的CG代码必须写在SubShader块中,不能写在Pass中,Unity会在背后为我们生成多个Pass。

16.2 - 编译指令

其中,**#pragma surface用于指明该编译指令是用于定义表面着色器的,在它的后面需要指明使用的表面函数(surfaceFunction)光照模型(lightModel)**,同时,还可以使用一些可选参数来控制表面着色器的一些行为。

在编译指令的背后,我们还能设置一些可选参数:

  • 自定义的修改函数。除了表面函数和光照模型外,表面着色器还可以支持其他两种自定义的函数:顶点修改函数(vertex:VertexFunction)和**最后的颜色修改函数(finalcolor:ColorFunction)**。
  • 阴影
    • addshadow 参数会为表面着色器生成一个阴影投射的Pass。
    • fullforwardshadows参数则可以在前向渲染路径中支持所有光源类型的阴影。
    • 如果我们不想对使用这个Shader 的物体进行任何阴影计算,就可以使用noshadow参数来禁用阴影。
  • 透明度混合和透明度测试
    • 我们可以通过alpha和 alphatest指令来控制透明度混合和透明度测试
    • alphatest:VariableName指令会使用名为VariableName的变量来剔除不满足条件的片元。此时,我们可能还需要使用上面提到的addshadow 参数来生成正确的阴影投射的Pass。
  • 光照,一些指令可以控制光照对物体的影响
    • noambient参数会告诉Unity不要应用任何环境光照或光照探针(light probe)。
    • novertexlights参数告诉Unity不要应用任何逐顶点光照。
    • noforwardadd 会去掉所有前向渲染中的额外的Pass。也就是说,这个 Shader只会支持一个逐像素的平行光,而其他的光源会按照逐顶点或SH的方法来计算光照影响。这个参数通常会用于移动平台版本的表面着色器中。
    • 还有一些用于控制光照烘焙、雾效模拟的参数,如nolightmapnofog等。
  • 控制代码的生成。
    • 一些指令还可以控制由表面着色器自动生成的代码,默认情况下,Unity会为一个表面着色器生成相应的前向渲染路径、延迟渲染路径使用的 Pass,这会导致生成的Shader文件比较大。如果我们确定该表面着色器只会在某些渲染路径中使用,就可以exclude_path:deferredexclude_path:forwardexclude_path:prepass来告诉Unity不需要为某些渲染路径生成代码。

16.3 - 两个结构体

16.3.1 - Input结构体

变量 描述
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结构体中定义一个名为half fog 的变量,把计算结果存储在该变量后进行输出。

16.3.2 - SurfaceOutput结构体

SurfaceOutput结构体中的变量和含义:

  • fixed3 Albedo:对光源的反射率。通常由纹理采样和颜色属性的乘积计算而得。

  • fixed3 Normal:表面法线方向。

  • fixed3 Emission:自发光。Unity通常会在片元着色器最后输出前(并在最后的顶点函数被调用前,如果定义了的话),使用类似下面的语句进行简单的颜色叠加:

    c.rgb+= o.Emission;

  • half Specular:高光反射中的指数部分的系数,影响高光反射的计算。例如,如果使用了内置的BlinnPhong光照函数,它会使用如下语句计算高光反射的强度:

    float spec = pow (nh, s.Specular*128.0) * s.Gloss;

  • fixed Gloss:高光反射中的强度系数。和上面的Specular类似,计算公式见上面的代码。一般在包含了高光反射的光照模型里使用。

  • fixed Alpha:透明通道。如果开启了透明度的话,会使用该值进行颜色混合。

16.3.3 - Unity在背后做了什么

Unity会在背后为表面着色器生成真正的顶点/片元着色器。

Unity 对该Pass的自动生成过程大致如下。

  1. 直接将表面着色器中CGPROGRAM 和 ENDCG之间的代码复制过来,这些代码包括了我们对Input结构体、表面函数、光照函数(如果自定了的话)等变量和函数的定义。这些函数和变量会在之后的处理过程中被当成正常的结构体和函数进行调用。
  2. Unity会分析上述代码,并据此生成顶点着色器的输出:v2f_surf结构体
  3. 接着生成顶点着色器
  4. 最后生成片元着色器
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
Shader "Unity Shaders Book/Chapter 17/Normal Extrusion" {
Properties {
_ColorTint ("Color Tint", Color) = (1,1,1,1)
_MainTex ("Base (RGB)", 2D) = "white" {}
_BumpMap ("Normalmap", 2D) = "bump" {}
_Amount ("Extrusion Amount", Range(-0.5, 0.5)) = 0.1
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 300

CGPROGRAM

// surf - which surface function.
// CustomLambert - which lighting model to use.
// vertex:myvert - use custom vertex modification function.
// finalcolor:mycolor - use custom final color modification function.
// addshadow - generate a shadow caster pass. Because we modify the vertex position, the shder needs special shadows handling.
// exclude_path:deferred/exclude_path:prepas - do not generate passes for deferred/legacy deferred rendering path.
// nometa - do not generate a “meta” pass (that’s used by lightmapping & dynamic global illumination to extract surface information).
#pragma surface surf CustomLambert vertex:myvert finalcolor:mycolor addshadow exclude_path:deferred exclude_path:prepass nometa
#pragma target 3.0

fixed4 _ColorTint;
sampler2D _MainTex;
sampler2D _BumpMap;
half _Amount;

struct Input {
float2 uv_MainTex;
float2 uv_BumpMap;
};

void myvert (inout appdata_full v) {
v.vertex.xyz += v.normal * _Amount;
}

void surf (Input IN, inout SurfaceOutput o) {
fixed4 tex = tex2D(_MainTex, IN.uv_MainTex);
o.Albedo = tex.rgb;
o.Alpha = tex.a;
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
}

half4 LightingCustomLambert (SurfaceOutput s, half3 lightDir, half atten) {
half NdotL = dot(s.Normal, lightDir);
half4 c;
c.rgb = s.Albedo * _LightColor0.rgb * (NdotL * atten);
c.a = s.Alpha;
return c;
}

void mycolor (Input IN, SurfaceOutput o, inout fixed4 color) {
color *= _ColorTint;
}

ENDCG
}
FallBack "Legacy Shaders/Diffuse"
}

16.4 - Surface Shader的缺点

除了性能比较差以外,表面着色器还无法完成一些自定义的渲染效果,例如10.2.2节中透明玻璃的效果。表面着色器的这些缺点让很多人更愿意使用自由的顶点/片元着色器来实现各种效果,尽管处理光照时这可能难度更大些。

  • 如果你需要和各种光源打交道,尤其是想要使用Unity 中的全局光照的话,你可能更喜欢使用表面着色器,但要时刻小心它的性能
  • 如果你需要处理的光源数目非常少,例如只有一个平行光,那么使用顶点/片元着色器是一个更好的选择;
  • 最重要的是,如果你有很多自定义的渲染效果,那么请选择顶点/片元着色器。

17 - 基于物理的渲染

本节主要参考了Naty Hoffman在SIGGRAPH 2013上做的名为 Background: Physics and Math of Shading 的演讲。

PBS 的优点在于,我们只需要一个万能的shader就可以渲染相当一大部分类型的材质,而不是使用传统的做法为每种材质写一个特定的 shader。同时,PBS可以保证在各种光照条件下,材质都可以自然地和光源进行交互,而不需要我们反复地调整材质参数。

17.1 - 光

在物理学中,光是一种电磁波。首先,光由太阳或其他光源中被发射出来,然后与场景中的对象相交,一些光线被吸收(absorption),而另一些则被散射(scattering),最后光线被一个感应器(例如我们的眼睛)吸收成像。

材质和光相交会产生两种物理现象:散射吸收

  • 光线被吸收是因为转换成了其他的能量,但是吸收不会改变传播方向。
  • 相反,散射不会改变光的能量,但是会改变光线的传播方向。

金属材质非金属材质的区别:

  • 金属材质具有很高的吸收系数,因此,所有被折射的光往往会被立刻吸收,被金属内部的自由电子转化成其他形式的能量。
  • 而非金属材质则会同时表现出吸收和散射两种现象,这些被散射出去的光又被称为次表面散射光(subsurface-scattered light)

17.1.2 - 双向反射分布函数(BRDF)

我们可以用辐射率(radiance)来量化光,在渲染中,我们通常会基于表面的入射光线的入射辐射率来计算出射辐射率,这个过程也被称作着色。

而要得到出射辐射率L,我们需要知道物体表面一点是如何和光进行交互的。而这个过程就可以使用 **BRDF(Bidirectional Reflectance Distribution Function,中文名称为双向反射分布函数)**来定量分析。

大多数情况下,BRDF可以用f(I,v)来表示,其中I为入射方向和v为观察方向(双向的含义)。这种情况下,绕着表面法线旋转入射方向或观察方向并不会影响BRDF 的结果,这种 BRDF被称为是各项同性(isotropic)的 BRDF。与之对应的则是各向异性(anisotropic)的BRDF。

BRDF 可以用于描述两种不同的物理现象:表面反射和次表面散射。

针对每种现象,BRDF通常会包含一个单独的部分来描述它们,一用于描述表面反射的部分被称为高光反射项(specular term),以及用于描述次表面散射漫反射项(diffuse term),

17.1.3 - 漫反射项

17.1.4 - 高光反射项

17.1.5 - Unity中的PBS实现

Unity使用的BRDF漫反射项:
$$
f_{diff}(I,v) = \frac{baseColor}{\pi}(1 + (F_{D90} - 1)(1-n \cdot I)^5)(1 + (F_{D90} - 1)(1-n \cdot I)^5)
$$

其中:
$$
F_{D90} = 0.5 + 2roughness(h \cdot I)^2
$$

Unity使用的高光反射项分布函数D(h):
$$
D_{GGX} = \frac {a^2}{\pi(a^2 - 1)(n \cdot h)_2 + 1)^2}
$$

其中:
$$
a = roughness^2
$$

阴影-遮掩函数 G(I,v,h)则使用了一种由GGX衍生出的Smith-Schlick模型:
$$
G(I,v,h) = \frac{1}{((n \cdot l)(1-k)+k)((n \cdot v)(1-k)+k)}
$$

菲涅耳反射 F(I,h)则使用了图形学中经常使用的Schlick菲涅耳近似等式:
$$
F(I,h) = F_0 + (1 - F_0)(1 - l \cdot h)^5
$$
其中F0表示高光反射系数,在Unity中指高光反射颜色。

17.2 - Unity5的Standard Shader

Unity支持两种流行的基于物理的工作流程:金属工作流(Metallic workflow)高光反射工作流(Specular workflow)

其中,金属工作流是默认的工作流程,对应的 Shader为 StandardShader。而如果想要使用高光反射工作流,就需要在材质的Shader下拉框中选择**Standard( Specular setup)**。

需要注意的是,通常来讲,使用不同的工作流可以实现相同的效果,只是它们使用的参数不同。

金属材质

  • 几乎没有漫反射,因为所有被吸收的光都会被自由电子立刻转化为其他形式的能量;

  • 有非常强烈的高光反射;

  • 高光反射通常是有颜色的,例如金子的反光颜色为黄色。

非金属材质

  • 大多数角度高光反射的强度比较弱,但在掠射角时高光反射强度反而会增强,即菲涅耳现象;
  • 高光反射的颜色比较单一;
  • 漫反射的颜色多种多样。

基于物理的渲染需要使用线性空间来进行相关计算。

示例项目 Shader Calibration Scene (https://www.assetstore.unity3d.com/en/#!/content/25422)中, Unity提供了两种工作量的参考

UnityMetallicChart

UnitySpecularChart

除了上述属性,我们还可以为 Standard Shader选择它使用的渲染模式,即材质面板上的Render Mode选项。Standard Shader支持4
种渲染模式,分别是OpaqueCutoutFadeTransparent

  • 其中,Opaque用于渲染最常见的不透明物体,这也是默认的渲染模式。
  • 对于像玻璃这样的材质,我们可以选择Transparent模式,在这个渲染模式下,Albedo属性的A通道用于控制材质的透明度。
  • 而在 Cutout渲染模式下,Albedo属性中纹理的A通道会成为一个掩码纹理而它的子属性Alpha Cutoff将是透明度测试时使用的阈值。
  • Fade模式和 Transparent模式是类似的,不同的是,在Transparent模式下,当材质的透明值不断降低时,它的反射仍然能被保留,而在Fade模式下,该材质的所有渲染效果都会逐渐从屏幕上淡出。

要想得到可信度更高的渲染结果,我们需要对不同材质使用合适的属性值,尤其是一些重要的属性值,例如AlbedoMetallicSpecular。当然,想要让整个场景的渲染结果令人满意,尤其包含了复杂光照的场景,仅仅有这些使用了PBS的材质是不够的,需要使用Unity提供的其他一些重要的技术,例如HDR格式的Skybox全局光照反射探针光照探针HDR屏幕后处理等。

17.3 - 基于物理渲染的实例

17.3.1 - 设置光照环境

  1. 本例的Skybox使用了一个HDR格式的Cubemap,可以让场景中的物体的反射更加真实。

  2. 我们还可以设置场景使用的环境光照,这些环境光照可以对场景中所有的物体表面产生影响。

    Lighting中可以选择环境光照的来源:来自场景使用的Skybox、使用渐变值或者是某个固定的颜色。

  3. 设置场景中的直接光照:一个平行光。保证平行光的方向和颜色与Skybox吻合。

    在平行光面板的烘焙选项(即 Baking)中,我们选择了Realtime模式,这意味着,场景中受平行光影响的所有物体都会进行实时的光照计算。当光源或场景中其他物体的位置、旋转角度等发生变化时,场景中的光照结果也会随之变化。

    然而,实时光照往往需要较大的性能消耗,对于移动平台这样资源比较短缺的平台,我们可以选择Baked模式,此时,Unity 会把该光源的光照效果烘焙到一张光照纹理(lightmap)中,这样我们就不用实时为物体计算复杂的光照,而只需要通过纹理采样来得到光照结果。选择烘焙模式的缺点在于,如果场景中的物体发生了移动,但是它的阴影等光照效果并不会发生变化。

    烘焙选项中的Mix模式则允许我们混合使用实时模式和烘焙模式,它会把场景中的静态物体(即那些被标识为 Static的物体)的光照烘焙到光照纹理中,但仍然会对动态物体产生实时光照。

  4. 计算间接光照

    除了 Standard Shader 外,Unity 还引入了一个重要的流水线实时全局光照(GlobeI Illumination,GI)流水线。使用GI,场景中的物体不仅可以受直接光照的影响,还可以接受间接光照的影响。在 Unity 中,间接光照指的就是那些被场景中其他物体反弹的光,这些间接光照会受反弹光的表面的颜色影响。

17.3.2 - 放置反射探针

在赛车游戏中,我们需要对车身或车窗使用反射映射的技术来模拟它们的反光材质。然而,如果我们永远使用同一个 Cubemap,那么,当赛车周围的场景发生较大变化时,就很容易出现“穿帮镜头”,因为车身或车窗的环境反射并没有随着环境变化而发生变化。一种解决办法是可以在脚本中控制何时生成从当前位置观察到的 Cubemap,而 Unity 5为我们提供了一种更加方便的途径,就是反射探针(Reflection Probes)。

反射探针的工作原理和光照探针(Light Probes)类似,它允许我们在场景中的特定位置上对整个场景的环境反射进行采样,并把采样结果存储在每个探针上。当游戏中包含反射效果的物体从这些探针附近经过时,Unity 会把从这些邻近探针存储的反射结果传递给物体使用的反射纹理。如果物体周围存在多个反射探针,Unity还会在这些反射结果之间进行插值,来得到平滑渐变的反射效果。

反射探针同样有3种类型:

  • Baked,这种类型的反射探针是通过提前烘焙来得到该位置使用的 Cubemap 的,在游戏运行时反射探针中存的Cubemap并不会发生变化。需要注意的是,这种类型的反射探针在烘焙时同样只会处理那些静态物体(即那些被标识为Reflection Probe Static的物体);
  • Realtime,这种类型则会实时更新当前的Cubemap,并且不受静态物体还是动态物体的影响。当然,这种类型的反射探针需要花费更多的处理时间,因此,在使用时应当非常小心它们的性能。幸运的是,Unity 允许我们从脚本中通过触发来精确控制反射探针的更新;
  • 最后一种类型是Custom,这种类型的探针既可以让我们从编辑器中烘焙它,也可以让我们使用一个自定义的Cubemap来作为反射映射,但自定义的 Cubemap不会被实时更新。

需要注意的是,在放置反射探针时,我们选取的位置并不是任意的。通常来说,反射探针该被放置在那些具有明显反射现象的物体的旁边,或是一些墙角等容易发生遮挡的物体周围。

使用反射探针往往会需要更多的计算时间。这些探针实际上也是通过在它的位置上放置一个摄像机,来渲染得到一个 Cubemap。如果我们把反弹次数设置的很大,或是使用实时渲染,那么这些探针很可能会造成性能瓶颈。

更多关于如何优化反射探针以及它的高级用法,参见Unity的官方手册(http://docs.unity3d.com/Manual/ReflectionProbes.html)。

17.3.3 - 调整材质

要得到真实可信的渲染效果,我们需要为场景中的物体指定合适的材质。需要再次提醒读者的是基于物理的渲染并不意味着一定要模拟像照片真实的效果。基于物理的渲染更多的好处在于,可以我们的场景在各种光照条件下都能得到令人满意的效果,同时不需要频繁地调整材质参数。

根据官方的示例项目Viking Village。这些材质可以为读者制作自己的材质提供一些参考,例如,场景中所有物体都使用了高光反射纹理(Specular Texture)、遮挡纹理(Occlusion Texture)、法线纹理(Normal Texture),一些材质还使用了细节纹理来提供更多的细节表现。

此外还可以在18.2.2节中复习如何针对不同类别的物体来调整他们使用的材质属性。

17.3.4 - 线性空间

在使用基于物理的渲染方法渲染整个场景时,我们应该使用线性空间(Linear Space)来得到最好的渲染效果。

17.4 - 答疑解惑

17.4.1 - 什么是全局光照

17.4.2 - 什么是伽马矫正

17.4.3 - 什么是HDR

17.4.4 - PBS适合什么样的游戏

17.5 - 拓展阅读

Unity官方提供了很多学习PBS的资料。

  • 在 Unity官方博客中的全局光照一文(global-illumination-in-unity-5)中,简明地阐述了全局光照的解决方案。
  • 在另外两篇博客(working-with-physically-based-Shading-a-practical-approach/、physically-based-shading-in-unity- 5-a-primer/)中,介绍了Standard Shader的用法和注意事项。

官方项目也是很好的学习资料。

  • Unity 开放了基于物理着色器的示例项目 Viking Village
  • 以及两个更小的示例项目Shader Calibration Seene 和Corridor Lighting Example来着重介绍如何使用Unity 5全新的Standard Shader和全局光照系统。

看过Unity 5宣传视频的读者想必对Unity 5制作出来的电影短片The Blacksmith 印象深刻,尽管Unity 没有开放出完整的工程,但把许多关键的技术实现放到了资源商店里,例如,人物角色使用的Shader、头发使用的Shader、人物阴影、大气次散射等,这些都是非常好的学习资料。

除此之外,Unity还提供了一些相关教程供新手学习,读者可以在图形的教程板块(http://unity3d.com/cn/learn/tutorials/topics/graphics)下找到很多相关教程。

近年来,Unity官方在Unite、SIGGRAPH等大会上也分享不少关于PBS的技术资料。

  • 在Unite2014会议上,Anton Hand在他的演讲中给出了很多关于如何创建PBS中使用的资源的最佳实践;
  • Renaldas Zioma和 Erland Korner讲解了如何在Unity 5中更加有效地使用PBS.
  • 在SIGGRPAH 2015会议上,来自 Unity的技术人员分享了The Blacksmith的环境制作过程。

如果读者希望更深入地学习PBS的理论和实践,可以在近年来的SIGGRAPH 课程上找到非常丰富的资料。

  • SIGGRAPH自2006年起开始出现与PBS相关的课程,更是连续4年(2012’2015)由来自各大游戏公司和影视公司的技术人员分享他们在PBS上的实践。例如在2012年的课程上,Disney 公布了他们在离线渲染时使用的 BRDF模型,这也是 Unity 等很多游戏引擎使用的PBR的理论基础。
  • Kostas Anagnostou在他的文章中列出了非常多的关于PBR的相关文章,包括我们上面提到的SIGGRAPH课程,强烈建议有兴趣的读者去浏览一番。

国内的相关资料则相对较少。

18 - 额外内容

18.1 - 深入了解渲染

Unity Shader实际是建立在OpenGLDirectX这样更加基础的图像编程按口上的。这样的封装可以为我们节省很多工作,但可能会影响我们对底层工作方式的理解。这些图像编程按口都有各自非常出色的学习资料,

这些内容相对比较高深,大都来源于行业内的精英对各种渲染技术的总结,希望深入了解渲染各个方面的读者一定不可以错过。

尽管本书关注的是游戏中使用的实时渲染技术,但一些基于光线追踪等方式的渲染方法同样是图形学中的重点。

  • 《Physically based rendering: From theory to implementation》一书中,作者介绍并实现了基于物理渲染的框架,这是学习光线追踪和PBS的非常好的资料。

最后,我们不得不提起被誉为图形程序员专著的《Real-time Rendering, third Edition》一书。在该书出版时,几乎涵盖了实时渲染中的所有相关技术,作者在书中给出了大量的参考又献,开在网上维护了一个专门的页面来总结实时渲染中使用的各个技术和资料。

在学术方面,图形学相关的会议和论坛是开阔视野、学习前沿渲染技术的绝佳途径。

  • SIGGRAPH 会议是图形学领域最顶级的会议,每年来自世界各地的顶尖学者和行业精英都会汇聚一堂,展示这一年中他们在图形学领域的工作和进展。
  • 与之类似的会议还有,SIGGRAPH Asia,EurographicsSymposium on Interactive 3D Graphics and Games 等会议,可以在Ke-Sen Huang的主页中找到历年在这些会议上发表的论文。

需要特别提出的是,每年SIGGRAPH上的SIGGRAPH Course中都会有很多来自游戏行业的技术人员分享他们在游戏图像方面的进展

  • 在第17章中提到的课程Physically Based Shading in Theory and Practice
  • Advances in Real-Time Rendering系列课程同样是非常出色的学习资料。在这个课程中,来自艺电、育碧、Epic等知名游戏公司的技术人员将阐述他们是如何在游戏中使用各种复杂的渲染技术来实现次世代游戏画面的。自2006年起,该课程已经在SIGGRAPH Course上连续举办了十届。
  • 另一个与游年的GDC 会议都戏息息相关的会议是游戏开发者会议(Game Developers Conference,GDC),每会汇集全世界的游戏开发者。自2009年,中国也迎来了GDC China,给中国的游戏开发者提供了更多的行业交流机会。

除了上述提到的书籍和会议外,一些非常有趣的网站也可以帮助开阔我们的视野。

  • Shadertoy网站上,你可以看到来自全世界的人们是如何只用一个片元着色器来实现各种或恢弘壮丽、或经典怀旧的场景的。
  • 与之类似的还有GLSL Sandbox Gallery网站。我们相信,在浏览了这些网站后,你会再一次被 Shader能实现的效果所震撼。