《Unity Shader入门精要》笔记

冯乐乐 著

封面

代码仓库

摘录整理。

我的实验环境:Windows 10, Unity 2020.3.26f1c1

前言

概括来说,Unity Shader是Shader上层的一个抽象。

第1篇 基础篇

第1章 欢迎来到shader的世界

我们之所以会觉得学习Shader比学习C#这样的编程语言更加困难,一个原因是因为Shader需要牵扯到整个渲染流程。当学习C++、C#这样的高级语言时,我们可以在不了解计算机架构的情况下仍然编写出实现各种功能的代码,这样的高级语言更符合人类的思维方式。然而,Shader并不是这样的。我们之所以要学习Shader,是想要学习如何把物体按照自己的意愿渲染到屏幕上,但是,Shader只是整个渲染流程中的一个子部分。虽然它很关键,但想要学习它,我们就需要了解整个渲染流程是如何进行的。和C++这样的高级语言不同,尽管Shader的编写语言已经达到了我们可以理解的程度,但Shader更多地是面向GPU的工作方式,所以它的一些语法对我们来说并不那么直观。

第2章 渲染流水线

2.1 综述

《Render-Time Rendering, Third Edition》一书中将一个渲染流程分成3个概念阶段:

  • 应用阶段(Application Stage)

    这个阶段由应用主导,通常由CPU负责实现。

    • 准备好场景数据
    • culling
    • 设置材质、纹理、shader
    • 输出渲染图元
  • 几何阶段(Geometry Stage)

    负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。通常在GPU上进行。

    • 通过对输入的渲染图元进行多步处理后,这一阶段将会输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。
  • 光栅化阶段(Rasterizer Stage)

    决定每个渲染图元中的哪些像素应该被绘制在屏幕上,在GPU上运行。

    • 对上一个阶段得到的逐顶点数据(例如纹理坐标、顶点颜色等)进行插值,然后再进行逐像素处理。
渲染流水线中的3个概念阶段

2.2 CPU和GPU之间的通信

渲染流水线的起点是CPU,即应用阶段。应用阶段大致可分为下面3个阶段:

(1)把数据加载到显存中。

(2)设置渲染状态。

(3)调用Draw Call。

2.3 GPU流水线

GPU的渲染流水线实现
  1. 顶点着色器(Vertex Shader)是完全可编程的,它通常用于实现顶点的空间变换、顶点着色等功能。一个最基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标(Normalized Device Coordinates , NDC)。

    变换到齐次裁剪坐标空间

    图中给出的坐标范围是OpenGL同时也是Unity使用的NDC,它的z分量范围在[-1, 1]之间,而在DirectX中,NDC的z分量范围是[0, 1]。

  2. 曲面细分着色器(Tessellation Shader)是一个可选的着色器,它用于细分图元。

  3. 几何着色器(Geometry Shader)同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive)的着色操作,或者被用于产生更多的图元。

  4. 裁剪(Clipping)的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的。例如,我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面。

  5. 屏幕映射(Screen Mapping)是不可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。OpenGL把屏幕的左下角当成最小的窗口坐标值,而DirectX则定义了屏幕的左上角为最小的窗口坐标值。

  6. 三角形设置(Triangle Setup)和三角形遍历(Triangle Traversal)阶段都是固定函数(Fixed-Function)的阶段。以三角形网格表示数据,找到哪些像素被三角网格覆盖。对应像素会生成一个片元,而片元中的状态是对3个顶点的信息迚行插值得到的。

  7. 片元着色器(Fragment Shader)是完全可编程的,它用于实现逐片元(Per-Fragment)的着色操作。在DirectX中,片元着色器被称为像素着色器(Pixel Shader),但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标。其局限在于仅可以影响单个片元,不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外,就是片元着色器可以访问到导数信息(gradient,或者说是derivative)。

  8. 逐片元操作(Per-Fragment Operations)阶段负责执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等,它不是可编程的,但具有很高的可配置性。逐片元操作(Per-Fragment Operations)是OpenGL中的说法,在DirectX中,这一阶段被称为输出合并阶段(Output-Merger)。

    片元 -> 模板测试-> 深度测试-> 混合 -> 颜色缓冲区

    模板测试和深度测试

    对于不透明物体,开发者可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。但对于半透明物体,我们就需要使用混合操作来让这个物体看起来是透明的。

    将深度测试提前执行的技术通常也被称为Early-Z技术。

  9. 为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲(Front Buffer)中的内容,而前置缓冲区是之前显示在屏幕上的图像。

2.4 一些容易困惑的地方

OpenGL和DirectX是图像应用编程接口,在硬件的基础上实现了一层抽象。这些接口用于渲染二维或三维图形。

一个应用程序向这些接口发送渲染命令,而这些接口会依次向显卡驱动(Graphics Driver)发送渲染命令,这些显卡驱动是真正知道如何和GPU通信的角色,正是它们把OpenGL或者DirectX的函数调用翻译成了GPU能够听懂的语言,同时它们也负责把纹理等数据转换成GPU所支持的格式。

着色语言是专门用于编写着色器的,常见的着色语言有DirectX的HLSL(High Level Shading Language)、OpenGL的GLSL(OpenGL Shading Language)以及NVIDIA的CG(C for Graphic)。HLSL、GLSL、CG都是“高级(High-Level)”语言,但这种高级是相对于汇编语言来说的,而不是像C#相对于C的高级那样。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言(Intermediate Language, IL)。这些中间语言再交给显卡驱动来翻译成真正的机器语言,即GPU可以理解的语言。

在游戏开发过程中,为了减少Draw Call的开销,有两点需要注意。(1)避免使用大量很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们。(2)避免使用过多的材质。尽量在不同的网格之间共用同一个材质。

2.5 那么,你明白什么是Shader了吗

Shader就是:

  • GPU流水线上一些可高度编程的阶段,而由着色器编译出来的最终代码是会在GPU上运行的(对于固定管线的渲染来说,着色器有时等同于一些特定的渲染设置);
  • 有一些特定类型的着色器,如顶点着色器、片元着色器等;
  • 依靠着色器我们可以控制流水线中的渲染细节,例如用顶点着色器来进行顶点变换以及传递数据,用片元着色器来进行逐像素的渲染。

第3章 Unity Shader基础

3.1 Unity Shader概述

使用流程:

(1)创建一个材质;

(2)创建一个Unity Shader,并把它赋给上一步中创建的材质;

(3)把材质赋给要渲染的对象;

(4)在材质面板中调整Unity Shader的属性,以得到满意的效果。

在Unity 5.2及以上版本中,Unity一共提供了4种Unity Shader模板供我们选择——Standard Surface Shader, Unlit Shader, Image Effect Shader以及Compute Shader。

3.2 Unity Shader的基础:ShaderLab

Unity Shader是Unity为开发者提供的高层级的渲染抽象层。

从设计上来说,ShaderLab类似于CgFX和Direct3D Effects(.FX)语言,它们都定义了要显示一个材质所需的所有东西,而不仅仅是着色器代码。

Unity在背后会根据使用的平台来把这些结构编译成真正的代码和Shader文件,而开发者只需要和Unity Shader打交道即可。

3.3 Unity Shader的结构

一个Unity Shader的基础结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//第一行都需要通过Shader语义来指定该Unity Shader的名字。
//通过在字符串中添加斜杠(“/”),可以控制Unity Shader在材质面板中出现的位置。
Shader "category/ShaderName" {
Properties {
// 属性
}
SubShader {
// 显卡A使用的子着色器
}
SubShader {
// 显卡B使用的子着色器
}
Fallback "VertexLit"
}

这些属性将会出现在材质面板中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Shader  "Custom/ShaderLabProperties"  {
Properties {
// Name ("display name", PropertyType) = DefaultValue
// Numbers and Sliders
_Int ("Int", Int) = 2
_Float ("Float", Float) = 1.5
_Range("Range", Range(0.0, 5.0)) = 3.0
// Colors and Vectors
_Color ("Color", Color) = (1,1,1,1)
_Vector ("Vector", Vector) = (2, 3, 6, 1)
// Textures
_2D ("2D", 2D) = "" {}
_Cube ("Cube", Cube) = "white" {}
_3D ("3D", 3D) = "black" {}
}
FallBack "Diffuse"
}

每一个Unity Shader文件可以包含多个SubShader语义块,但最少要有一个。当Unity需要加载这个Unity Shader时,Unity会扫描所有的SubShader语义块,然后选择第一个能够在目标平台上运行的SubShader。如果都不支持的话,Unity就会使用Fallback语义指定的Unity Shader。也可以使用Fallback Off关闭Fallback功能。

1
2
3
4
5
6
7
8
9
SubShader  {            
// 可选的
[Tags]
// 可选的
[RenderSetup]
Pass {
}
// Other Passes
}

SubShader中定义了一系列Pass以及可选的状态([RenderSetup])和标签([Tags])设置。

每个Pass定义了一次完整的渲染流程,但如果Pass的数目过多,往往会造成渲染性能的下降。因此,我们应尽量使用最小数目的Pass。

状态和标签同样可以在Pass声明。对于状态设置来说语法相同,SubShader得设置会用于所有的Pass。但是,SubShader中的一些标签设置是特定的,这些标签设置和Pass中使用的标签不一样。

状态

ShaderLab提供了一系列渲染状态的设置指令,这些指令可以设置显卡的各种状态,例如是否开启混合/深度测试等。

常见的渲染状态设置选项

标签

标签是一个键值对,它的键和值都是字符串类型。这些键值对是SubShader和渲染引擎之间的沟通桥梁。

SubShader的标签类型

Pass

1
2
3
4
5
6
Pass  {            
[Name]
[Tags]
[RenderSetup]
// Other code
}

定义名称

1
Name  "MyPassName"

通过这个名称,我们可以使用ShaderLab的UsePass命令来直接使用其他Unity Shader中的Pass。

1
UsePass  "MyShader/MYPASSNAME"

由于Unity内部会把所有Pass的名称转换成大写字母的表示,因此,在使用UsePass命令时必须使用大写形式的名字

Pass的标签类型

Unity Shader还支持一些特殊的Pass:

  • UsePass:如我们之前提到的一样,可以使用该命令来复用其他Unity Shader中的Pass
  • GrabPass:该Pass负责抓取屏幕并将结果存储在一张纹理中,以用于后续的Pass处理

3.4 Unity Shader的形式

表面着色器是Unity对顶点/片元着色器的更高一层的抽象。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shader  "Custom/Simple  Surface  Shader"  {            
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float4 color : COLOR;
};
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = 1;
}
ENDCG
}
Fallback "Diffuse"
}

表面着色器被定义在SubShader语义块(而非Pass语义块)中的CGPROGRAM和ENDCG之间。原因是,表面着色器不需要开发者关心使用多少个Pass、每个Pass如何渲染等问题,Unity会在背后为我们做好这些事情。

CGPROGRAM和ENDCG之间的代码是使用CG/HLSL编写的,它的语法和标准的CG/HLSL语法几乎一样,但还是有细微的不同,例如有些原生的函数和用法Unity并没有提供支持。

一个非常简单的顶点/片元着色器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Shader  "Custom/Simple  VertexFragment  Shader"  {
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert(float4 v : POSITION) : SV_POSITION {
return mul (UNITY_MATRIX_MVP, v);
}
fixed4 frag() : SV_Target {
return fixed4(1.0,0.0,0.0,1.0);
}
ENDCG
}
}
}

附录

在传统的Shader中,我们仅可以编写特定类型的Shader,例如顶点着色器、片元着色器等。而在Unity Shader中,我们可以在同一个文件里同时包含需要的顶点着色器和片元着色器代码。

在传统的Shader中,我们无法设置一些渲染设置,例如是否开启混合、深度测试等,这些是开发者在另外的代码中自行设置的。而在Unity Shader中,我们通过一行特定的指令就可以完成这些设置。

在传统的Shader中,我们需要编写冗长的代码来设置着色器的输入和输出,要小心地处理这些输入输出的位置对应关系等。而在Unity Shader中,我们只需要在特定语句块中声明一些属性,就可以依靠材质来方便地改变这些属性。而且对于模型自带的数据(如顶点位置、纹理坐标、法线等), Unity Shader也提供了直接访问的方法,不需要开发者自行编码来传给着色器。

第4章 学习Shader所需的数学基础

4.8 Unity Shader的内置变量(数学篇)

Unity内置的变换矩阵
Unity内置的摄像机和屏幕参数

附录

通常在变换顶点时,我们都是使用右乘的方式来按列矩阵进行乘法。这是因为,Unity提供的内置矩阵(如UNITY_MATRIX_MVP等)都是按列存储的。但有时,我们也会使用左乘的方式,这是因为可以省去对矩阵转置的操作(\(Ax=(x^TA^T)^T\))。

Unity在脚本中提供了一种矩阵类型——Matrix4x4。脚本中的这个矩阵类型则是采用列优先的方式。这与Unity Shader中的规定不一样。

参见图形API中的矩阵表示与运算 - Homeworld

OpenGL和DirectX 10以后的版本认为像素中心对应的是浮点值中的0.5。VPOS/WPOS语义定义的输入是一个float4类型的变量。我们已经知道它的xy值代表了在屏幕空间中的像素坐标。如果屏幕分辨率为400 × 300,那么x的范围就是[0.5,400.5], y的范围是[0.5,300.5]。(VPOS是HLSL中对屏幕坐标的语义,而WPOS是CG中对屏幕坐标的语义。)

在Unity中,VPOS/WPOS的z分量范围是[0,1],在摄像机的近裁剪平面处,z值为0,在远裁剪平面处,z值为1。对于w分量,我们需要考虑摄像机的投影类型。如果使用的是透视投影,那么w分量的范围是\([\frac{1}{Near},\frac{1}{Far}]\),Near和Far对应了在Camera组件中设置的近裁剪平面和远裁剪平面距离摄像机的远近;如果使用的是正交投影,那么w分量的值恒为1。

第2篇 初级篇

第5章 开始Unity Shader学习之旅

5.1 本书使用的软件和环境

本书使用的Unity版本是Unity 5.2.1免费版。

本书工程编写的系统环境是Mac OS X 10.9.5。如果读者使用的是其他系统,绝大部分情况也不会有任何问题。但有时会由于图像编程接口的种类和版本不同而有一些差别,这是因为Mac使用的图像编程接口是基于OpenGL的,而其他平台如Windows,可能使用的是DirectX。例如,在OpenGL中,渲染纹理(Render Texture)的(0, 0)点是在左下角,而在DirectX中,(0, 0)点是在左上角。

这本书是2016年写的,比较老了。这里我使用2020.3.26f1c1

5.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
}
SubShader {
Pass {
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

uniform fixed4 _Color;
//定义顶点着色器的输入,由使用该材质的Mesh Render组件提供
struct a2v {
//POSITION语义:顶点坐标
float4 vertex : POSITION;
//NORMAL语义:法线方向
float3 normal : NORMAL;
//TEXCOORD0语义:第一套纹理坐标
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
fixed3 color : COLOR0;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 c = i.color;
c *= _Color.rgb;
return fixed4(c, 1.0);
}

ENDCG
}
}
}
结果1

5.3 强大的援手:Unity提供的内置文件和变量

包含文件(include file),是类似于C++中头文件的一种文件。在Unity中,它们的文件后缀是.cginc。在编写Shader时,我们可以使用#include指令把这些文件包含进来,这样我们就可以使用Unity为我们提供的一些非常有用的变量和帮助函数。

CGIncludes文件夹在Mac上位于:/Applications/Unity/Unity.app/Contents/CGIncludes;在Windows上位于:Unity的安装路径/Data/CGIncludes。

Unity中一些常用的包含文件
UnityCG.cginc中一些常用的结构体
UnityCG.cginc中一些常用的帮助函数

5.4 Unity提供的CG/HLSL语义

语义可以让Shader知道从哪里读取数据,并把数据输出到哪里,它们在CG/HLSL的Shader流水线中是不可或缺的。需要注意的是,Unity并没有支持所有的语义。

在DirectX 10以后,有了一种新的语义类型,就是系统数值语义(system-value semantics)。这类语义是以SV开头的,SV代表的含义就是系统数值(system-value)。这些语义在渲染流水线中有特殊的含义。例如在上面的代码中,我们使用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们来完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。

一些Shader会使用POSITION而非SV_POSITION来修饰顶点着色器的输出。SV_POSITION是DirectX 10中引入的系统数值语义,在绝大多数平台上,它和POSITION语义是等价的,但在某些平台(例如索尼PS4)上必须使用SV_POSITION来修饰顶点着色器的输出,否则无法让Shader正常工作。同样的例子还有COLOR和SV_Target。因此,为了让我们的Shader有更好的跨平台性,对于这些有特殊含义的变量我们最好使用以SV开头的语义进行修饰。

从应用阶段传递模型数据给顶点着色器时Unity支持的常用语义
从顶点着色器传递数据给片元着色器时Unity使用的常用语义
片元着色器输出时Unity支持的常用语义

5.5 程序员的烦恼:Debug

Shader可以直接使用颜色代表数据进行可视化来Debug。也可以借助帧调试器一类的工具。

5.6 小心:渲染平台的差异

在OpenGL(OpenGL ES也是)中,(0, 0)点对应了屏幕的左下角,而在DirectX(Meta l也是)中,(0, 0)点对应了左上角。

大多数情况下,这样的差异并不会对我们造成任何影响。但当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取行任何措施的话,就会出现纹理翻转的情况。幸运的是,Unity在背后为我们处理了这种翻转问题——当在DirectX平台上使用渲染到纹理技术时,Unity会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。

如果我们需要同时处理多张渲染图像(前提是开启了抗锯齿),例如需要同时处理屏幕图像和法线纹理,这些图像在竖直方向的朝向就可能是不同的(只有在DirectX这样的平台上才有这样的问题)。

5.7 Shader整洁之道

CG/HLSL中3种精度的数值类型

类型 精度
float 最高精度,通常32位存储
half 中等精度,通常16位存储,精度范围-60000~+60000
fixed 最低精度,通常11位存储,精度范围-2.0~+2.0

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

GPU使用了不同于CPU的技术来实现分支语句,在最坏的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此,我们不鼓励在Shader中使用流程控制语句,因为它们会降低GPU的并行处理操作(尽管在现代的GPU上已经有了改进)。

第6章 Unity中的基础光照

6.1 我们是如何看到这个世界的

着色(shading)指的是,根据材质属性(如漫反射属性等)、光源信息(如光源方向、辐照度等),使用一个等式去计算沿某个观察方向的出射度的过程。我们也把这个等式称为光照模型(Lighting Model)。

当给定模型表面上的一个点时,BRDF包含了对该点外观的完整的描述。在图形学中,BRDF(Bidirectional Reflectance Distribution Function)大多使用一个数学公式来表示,并且提供了一些参数来调整材质属性。通俗来讲,当给定入射光线的方向和辐照度后,BRDF可以给出在某个出射方向上的光照能量分布。

本章涉及的BRDF都是对真实场景进行理想化和简化后的模型,也就是说,它们并不能真实地反映物体和光线之间的交互,这些光照模型被称为是经验模型

6.2 标准光照模型

虽然光照模型有很多种类,但在早期的游戏引擎中往往只使用一个光照模型,这个模型被称为标准光照模型。实际上,在BRDF理论被提出之前,标准光照模型就已经被广泛使用了。

在1975年,著名学者裴祥风(Bui Tuong Phong)提出了标准光照模型背后的基本理念。标准光照模型只关心直接光照(direct light),也就是那些直接从光源发射出来照射到物体表面后,经过物体表面的一次反射直接进入摄像机的光线。

它把进入到摄像机内的光线分为4个部分,每个部分使用一种方法来计算它的贡献度:

  • 自发光(emissive)部分,本书使用cmissive来表示。这个部分用于描述当给定一个方向时,一个表面本身会向该方向发射多少辐射量。需要注意的是,如果没有使用全局光照(global illumination)技术,这些自发光的表面并不会真的照亮周围的物体,而是它本身看起来更亮了而已。

  • 漫反射(diffuse)部分,本书使用cdiffuse来表示。这个部分用于描述,当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。

    因为反射完全随机,可以认为在任何反射方向上的分布都是一样的,因此入射光线的角度很重要。

    漫反射光照符合兰伯特定律(Lambert's law):反射光线的强度与表面法线和光源方向之间夹角的余弦值成正比。 \[ c_{diffuse}=(c_{light}·m_{diffuse})\max(0,\hat{n}·\hat{l}) \] \(c_{light}\)是光源颜色,\(m_{diffuse}\)是材质的漫反射颜色, \(\hat{n}\)是表面法线, \(\hat{l}\)是指向光源的单位矢量。max防止物体被从后面来的光源照亮。

  • 高光反射(specular)部分,本书使用cspecular来表示。这个部分用于描述当光线从光源照射到模型表面时,该表面会在完全镜面反射方向散射多少辐射量。

    计算高光反射需要知道表面法线\(\hat{n}\)、视角方向\(\hat{v}\)、光源方向\(\hat{l}\)、反射方向\(\hat{r}\)。反射方向可以由表面法线和光源方向计算。 \[ \hat{r}=2(\hat{n}·\hat{I})\hat{n}-\hat{l} \] 利用Phong模型来计算高光反射: \[ c_{specular}=(c_{light}·m_{specular})\max(0,\hat{v}·\hat{r})^{m_{gloss}} \] \(m_{gloss}\)是材质的光泽度(gloss),也被称为反光度(shininess)。它用于控制高光区域的“亮点”有多宽,gloss越大,亮点就越小

    Blinn模型引入了一个新的矢量\(\hat{h}\)\(\hat{h}=\frac{\hat{v}+\hat{l}}{ | \hat{v}+\hat{l} | }\)\[ c_{specular}=(c_{light}·m_{specular})\max(0,\hat{n}·\hat{h})^{m_{gloss}} \]

  • 环境光(ambient)部分,本书使用cambient来表示。它用于描述其他所有的间接光照。

    通常是一个全局变量,即场景中的所有物体都使用这个环境光。

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

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

标准光照模型有很多不同的叫法。例如,一些资料中称它为Phong光照模型,因为裴祥风(Bui Tuong Phong)首先提出了使用漫反射和高光反射的和来对反射光照进行建模的基本思想,并且提出了基于经验的计算高光反射的方法(用于计算漫反射光照的兰伯特模型在那时已经被提出了)。而后,由于Blinn的方法简化了计算而且在某些情况下计算更快,我们把这种模型称为Blinn-Phong光照模型。

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

6.3 Unity中的环境光和自发光

环境光在window->Rendering->Lighting->Environment中调节。

计算自发光只需要在片元着色器输出最后的颜色之前,把材质的自发光颜色添加到输出颜色上。

6.4 在Unity 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
53
54
55
56
57
58
59
60
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Unity Shaders Book/Chapter 6/Diffuse Vertex-Level" {
Properties {
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader {
Pass {
//定义该Pass在Unity的光照流水线中的角色
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Diffuse;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
fixed3 color : COLOR;
};

v2f vert(a2v v) {
v2f o;
// Transform the vertex from object space to projection space
o.pos = UnityObjectToClipPos(v.vertex);

// Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

// Transform the normal from object space to world space
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
// Get the light direction in world space
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
// Compute diffuse term
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));

o.color = ambient + diffuse;

return o;
}

fixed4 frag(v2f i) : SV_Target {
return fixed4(i.color, 1.0);
}

ENDCG
}
}
FallBack "Diffuse"
}

注意,管线会自动在顶点着色器和片元着色器之间进行插值。

逐像素的漫反射光照着色器类似,只是把颜色的计算放在了片元着色器中。

半兰伯特光照模型: \[ c_{diffuse}=(c_{light}·m_{diffuse})(0.5(\hat{n}·\hat{I})+0.5) \] 不使用max,而是把\(\hat{n}·\hat{I}\)\([-1,1]\)映射到\([0,1]\)。没有任何物理依据,仅仅是一个视觉加强技术。

左起逐顶点漫反射光照、逐像素漫反射光照、逐像素半兰伯特光照:

结果2

6.5 在Unity Shader中实现高光反射光照模型

该部分主干代码与上节相同,把计算公式换掉即可。

包括逐顶点的高光反射光照、逐像素的高光反射光照(Phong光照模型)和Blinn-Phong高光反射光照。

6.6 召唤神龙:使用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
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
Shader "Unity Shaders Book/Chapter 6/Blinn-Phong Use Built-in Functions" {
Properties {
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(1.0, 500)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float4 worldPos : TEXCOORD1;
};

v2f vert(a2v v) {
v2f o;
o.pos = 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;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

fixed3 worldNormal = normalize(i.worldNormal);
// fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

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

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

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

ENDCG
}
}
FallBack "Specular"
}

第7章 基础纹理

纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射(texture mapping)技术,我们可以把一张图“黏”在模型表面,逐纹素(texel)(纹素的名字是为了和像素进行区分)地控制模型的颜色。

在美术人员建模的时候,通常会在建模软件中利用纹理展开技术把纹理映射坐标(texture-mapping coordinates)存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维变量(u, v)来表示,其中u是横向坐标,而v是纵向坐标。因此,纹理映射坐标也被称为UV坐标,通常都被归一化到[0, 1]范围内。对于不在[0, 1]范围内的纹理坐标,与之关系紧密的是纹理的平铺模式。

7.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
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
Shader "Unity Shaders Book/Chapter 7/Single Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
//在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中,ST是缩放(scale)和平移(translation)的缩写。_MainTex_ST可以让我们得到该纹理的缩放和平移(偏移)值,_MainTex_ST.xy存储的是缩放值,而_MainTex_ST.zw存储的是偏移值。这些值可以在材质面板的纹理属性中调节。
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

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

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// Or just call the built-in function o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

// Use the texture to sample the diffuse color
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);

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

ENDCG
}
}
FallBack "Specular"
}
结果3

Wrap Mode : 一种是Repeat,在这种模式下,如果纹理坐标超过了1,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复;另一种是Clamp,在这种模式下,如果纹理坐标大于1,那么将会截取到1,如果小于0,那么将会截取到0。

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

7.2 凹凸映射

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

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

  • 使用一张高度纹理(height map)来模拟表面位移(displacement),然后得到一个修改后的法线值,这种方法也被称为高度映射(height mapping)
  • 使用一张法线纹理(normal map)来直接存储表面法线,这种方法又被称为法线映射(normal mapping)。

由于法线方向的分量范围在[-1, 1],而像素的分量范围为[0, 1],因此我们需要做一个映射将法线方向存储到法线纹理中。

法线纹理分类 :

  • 模型空间的法线纹理(object-space normal map)

    • 实现简单

    • 这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息,因此在边界处通过插值得到的法线可以平滑变换

  • 切线空间的法线纹理(tangent-space normal map)

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

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

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

  1. 在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下。
  2. 在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。

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

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

对于第一种方法,P148在切线空间下计算法线光照一节是有问题的:Issue #45 · candycat1992/Unity_Shaders_Book · GitHub

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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
// 和法线方向normal不同,tangent的类型是float4,而非float3,这是因为我们需要使用tangent.w分量来决定切线空间中的坐标轴——副切线的方向性。
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

// Unity doesn't support the 'inverse' function in native shader
// so we write one by our own
// Note: this function is just a demonstration, not too confident on the math or the speed
// Reference: http://answers.unity3d.com/questions/218333/shader-inversefloat4x4-function.html
float4x4 inverse(float4x4 input) {
#define minor(a,b,c) determinant(float3x3(input.a, input.b, input.c))

float4x4 cofactors = float4x4(
minor(_22_23_24, _32_33_34, _42_43_44),
-minor(_21_23_24, _31_33_34, _41_43_44),
minor(_21_22_24, _31_32_34, _41_42_44),
-minor(_21_22_23, _31_32_33, _41_42_43),

-minor(_12_13_14, _32_33_34, _42_43_44),
minor(_11_13_14, _31_33_34, _41_43_44),
-minor(_11_12_14, _31_32_34, _41_42_44),
minor(_11_12_13, _31_32_33, _41_42_43),

minor(_12_13_14, _22_23_24, _42_43_44),
-minor(_11_13_14, _21_23_24, _41_43_44),
minor(_11_12_14, _21_22_24, _41_42_44),
-minor(_11_12_13, _21_22_23, _41_42_43),

-minor(_12_13_14, _22_23_24, _32_33_34),
minor(_11_13_14, _21_23_24, _31_33_34),
-minor(_11_12_14, _21_22_24, _31_32_34),
minor(_11_12_13, _21_22_23, _31_32_33)
);
#undef minor
return transpose(cofactors) / determinant(input);
}

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

///
/// Note that the code below can handle both uniform and non-uniform scales
///

// Construct a matrix that transforms a point/vector from tangent space to world space
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;

/*
float4x4 tangentToWorld = float4x4(worldTangent.x, worldBinormal.x, worldNormal.x, 0.0,
worldTangent.y, worldBinormal.y, worldNormal.y, 0.0,
worldTangent.z, worldBinormal.z, worldNormal.z, 0.0,
0.0, 0.0, 0.0, 1.0);
// The matrix that transforms from world space to tangent space is inverse of tangentToWorld
float3x3 worldToTangent = inverse(tangentToWorld);
*/

//wToT = the inverse of tToW = the transpose of tToW as long as tToW is an orthogonal matrix.
// xyz轴
float3x3 worldToTangent = float3x3(worldTangent, worldBinormal, worldNormal);

// Transform the light and view dir from world space to tangent space
o.lightDir = mul(worldToTangent, WorldSpaceLightDir(v.vertex));
o.viewDir = mul(worldToTangent, WorldSpaceViewDir(v.vertex));

///
/// Note that the code below can only handle uniform scales, not including non-uniform scales
///

// Compute the binormal
// float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w;
// // Construct a matrix which transform vectors from object space to tangent space
// float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
// Or just use the built-in macro
// TANGENT_SPACE_ROTATION;
//
// // Transform the light direction from object space to tangent space
// o.lightDir = mul(rotation, normalize(ObjSpaceLightDir(v.vertex))).xyz;
// // Transform the view direction from object space to tangent space
// o.viewDir = mul(rotation, normalize(ObjSpaceViewDir(v.vertex))).xyz;

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);

// Get the texel in the normal map
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal;
// If the texture is not marked as "Normal map"
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
// tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// Or mark the texture as "Normal map", and use the built-in funciton
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);
}

ENDCG
}
}
FallBack "Specular"
}

第二种方法:

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
Shader "Unity Shaders Book/Chapter 7/Normal Map In World Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
// 从切线空间到世界空间的变换矩阵
// 一个插值寄存器最多只能存储float4大小的变量,对于矩阵这样的变量,我们可以把它们按行拆成多个变量再进行存储。
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

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;

// Compute the matrix that transform directions from tangent space to world space
// Put the world position in w component for optimization
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;
}

fixed4 frag(v2f i) : SV_Target {
// Get the position in world space
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
// Compute the light and view dir in world space
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

// Get the normal in tangent space
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));
// Transform the normal from tangent space to world space
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);
}

ENDCG
}
}
FallBack "Specular"
}

结果4

7.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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
Shader "Unity Shaders Book/Chapter 7/Ramp Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_RampTex ("Ramp Tex", 2D) = "white" {}
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _RampTex;
float4 _RampTex_ST;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

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

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = UnityObjectToWorldNormal(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);
return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

// 半兰伯特光照模型 Use the texture to sample the diffuse color
fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
// 由于_RampTex实际就是一个一维纹理(它在纵轴方向上颜色不变),因此纹理坐标的u和v方向我们都使用了halfLambert。
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(worldNormal, halfDir)), _Gloss);

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

ENDCG
}
}
FallBack "Specular"
}
结果5

7.4 遮罩纹理

使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值(例如texel.r)来与某种表面属性进行相乘,这样,当该通道的值为0时,可以保护表面不受该属性的影响。总而言之,使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。

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
Shader "Unity Shaders Book/Chapter 7/Mask Texture" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale("Bump Scale", Float) = 1.0
_SpecularMask ("Specular Mask", 2D) = "white" {}
_SpecularScale ("Specular Scale", Float) = 1.0
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
// 为主纹理_MainTex、法线纹理_BumpMap和遮罩纹理_SpecularMask定义了它们共同使用的纹理属性变量_MainTex_ST。这意味着,在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3个纹理的采样。使用这种方式可以让我们节省需要存储的纹理坐标数目,如果我们为每一个纹理都使用一个单独的属性变量TextureName_ST,那么随着使用的纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMask;
float _SpecularScale;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
TANGENT_SPACE_ROTATION;
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
return o;
}

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

fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
// Get the mask value
fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale;
// Compute specular term with the specular mask
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;

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

ENDCG
}
}
FallBack "Specular"
}

在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的RGBA四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在R通道,把边缘光照的强度存储在G通道,把高光反射的指数部分存储在B通道,最后把自发光强度存储在A通道。

结果6

第8章 透明效果

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

  • 透明度测试(Alpha Test)

    这种方法其实无法得到真正的半透明效果。

    透明度测试是不需要关闭深度写入的,它和其他不透明物体最大的不同就是它会根据透明度来舍弃一些片元。虽然简单,但是它产生的效果也很极端,要么完全透明,即看不到,要么完全不透明,就像不透明物体那样。

  • 透明度混合(Alpha Blending)

    这种方法可以得到真正的半透明效果。

    它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序;但没有关闭深度测试,当使用透明度混合渲染一个片元时,还是会比较它的深度值与当前深度缓冲中的深度值,如果它的深度值距离摄像机更远,那么就不会再进行混合操作。

    因此,当一个不透明物体出现在一个透明物体的前面,而我们先渲染了不透明物体,它仍然可以正常地遮挡住透明物体。对于透明度混合来说深度缓冲是只读的。

8.1 为什么渲染顺序很重要

对于透明度混合技术,需要关闭深度写入。否则,一个半透明表面背后的表面本来是可以透过它被我们看到的,但由于深度测试时判断结果是该半透明表面距离摄像机更近,导致后面的表面将会被剔除,我们也就无法透过半透明表面看到后面的物体了。

当关闭了深度写入后,应该在不透明物体渲染完之后再渲染半透明物体。

  1. 先渲染所有不透明物体,并开启它们的深度测试和深度写入。
  2. 把半透明物体按它们距离摄像机的远近进行排序,然后按照从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入。

排序问题很难解决。

8.2 Unity Shader的渲染顺序

Unity为了解决渲染顺序的问题提供了渲染队列(render queue),可以使用SubShader的Queue标签来决定我们的模型将归于哪个渲染队列。

Unity在内部使用一系列整数索引来表示每个渲染队列,且索引号越小表示越早被渲染。在Unity 5中,Unity提前定义了5个渲染队列(与Unity 5之前的版本相比多了一个AlphaTest渲染队列),在每个队列中间我们可以使用其他队列。

Unity提前定义的5个渲染队列

8.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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Shader "Unity Shaders Book/Chapter 8/Alpha Test" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
}
SubShader {
// 通常,使用了透明度测试的Shader都应该在SubShader中设置这三个标签。
Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}

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

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

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

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

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

// Alpha test
clip (texColor.a - _Cutoff);
// Equal to
// if ((texColor.a - _Cutoff) < 0.0) {
// discard;
// }

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);
}

ENDCG
}
}
FallBack "Transparent/Cutout/VertexLit"
}
结果7

8.4 透明度混合

为了进行混合,我们需要使用Unity提供的混合命令——Blend。Blend是Unity提供的设置混合模式的命令。想要实现半透明的效果就需要把当前自身的颜色和已经存在于颜色缓冲中的颜色值进行混合,混合时使用的函数就是由该指令决定的。

ShaderLab的Blend命令
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
Shader "Unity Shaders Book/Chapter 8/Alpha Blend" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader {
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}

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

ZWrite Off
// 上面表格中第二种语义
// DstColor_new = SrcAlpha × SrcColor + (1 - SrcAlpha) × DstColor_old
Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};

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

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

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));
//与上节相比,只是移除了透明度测试的代码,并设置了该片元着色器返回值中的透明通道,它是纹理像素的透明通道和材质参数_AlphaScale的乘积。
return fixed4(ambient + diffuse, texColor.a * _AlphaScale);
}

ENDCG
}
}
FallBack "Transparent/VertexLit"
}
结果8

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

如果使用上一节的shader渲染Knot模型,会得到以下结果。

当模型本身有复杂的遮挡关系或是包含了复杂的非凸网格的时候,就会有各种各样因为排序错误而产生的错误的透明效果。

结果9

只需要在渲染Pass之前增加一个Pass:

1
2
3
4
Pass {
ZWrite On
ColorMask 0
}

这个新添加的Pass的目的仅仅是为了把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。因此,Pass的第一行开启了深度写入。在第二行,我们使用了一个新的渲染命令——ColorMask。在ShaderLab中,ColorMask用于设置颜色通道的写掩码(write mask)。它的语义如下:

1
ColorMask RGB | A | 0 | 其他任何R、G、B、A的组合

设为0时,意味着该Pass不写入任何颜色通道,即不会输出任何颜色。

结果10

8.6 ShaderLab的混合命令

混合还有很多其他用处,不仅仅是用于透明度混合。

当片元着色器产生一个颜色的时候,可以选择与颜色缓存中的颜色进行混合。这样一来,混合就和两个操作数有关:源颜色(source color)和目标颜色(destination color)。源颜色,我们用S表示,指的是由片元着色器产生的颜色值;目标颜色,我们用D表示,指的是从颜色缓冲中读取到的颜色值。对它们进行混合后得到的输出颜色,我们用O表示,它会重新写入到颜色缓冲中。需要注意的是,当我们谈及混合中的源颜色、目标颜色和输出颜色时,它们都包含了RGBA四个通道的值,而并非仅仅是RGB通道。

参考8.4中列出的Blend命令。命令使用的混合因子如下:

ShaderLab中的混合因子

可以使用ShaderLab的BlendOp BlendOperation命令决定源颜色和目标颜色与它们对应的混合因子相乘后两者之间的操作。

当使用Min或Max混合操作时,混合因子实际上是不起任何作用的,它们仅会判断原始的源颜色和目的颜色之间的比较结果。

ShaderLab中的混合操作
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

// 变亮(Lighten)
BlendOp Max
Blend One One

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

// 线性减淡(Linear Dodge)
Blend One One

8.7 双面渲染的透明效果

默认情况下渲染引擎剔除了物体背面(相对于摄像机的方向)的渲染图元,而只渲染了物体的正面。如果我们想要得到双面渲染的效果,可以使用Cull指令来控制需要剔除哪个面的渲染图元。在Unity中,Cull指令的语法如下:

1
Cull  Back  |  Front  |  Off

Back:背对着摄像机的渲染图元就不会被渲染,这也是默认情况下

Front:那么那些朝向摄像机的渲染图元就不会被渲染

Off:就会关闭剔除功能

对于透明度测试,只需要在8.3的Shader的Pass中添加Cull Off即可。

对于透明度混合,只需要把8.4的Shader的Pass复制一个,在两个Pass中分别使用Cull指令剔除不同朝向的渲染图元。这是因为关闭了深入写入,无法保证同一个物体的正面和背面图元的渲染顺序,就有可能得到错误的半透明效果。(这个例子我只用一个Pass Cull Off也没有区别)

结果11

第3篇 中级篇

第9章 更复杂的光照

9.1 Unity的渲染路径

在Unity里,渲染路径(Rendering Path)决定了光照是如何应用到Unity Shader中的。只有为Shader正确地选择和设置了需要的渲染路径,该Shader的光照计算才能被正确执行。

在Unity 5.0版本之前,主要有3种:前向渲染路径(Forward Rendering Path)、延迟渲染路径(Deferred Rendering Path)和顶点照明渲染路径(Vertex Lit Rendering Path)。但在Unity 5.0版本以后,Unity做了很多更改,主要有两个变化:首先,顶点照明渲染路径已经被Unity抛弃(但目前仍然可以对之前使用了顶点照明渲染路径的Unity Shader兼容);其次,新的延迟渲染路径代替了原来的延迟渲染路径(同样,目前也提供了对较旧版本的兼容)。

2020.3.26版本在Edit->Project Settings->Graphics->Tier Settings中设置默认Rendering Path。每个相机可以单独覆盖设置。

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

前向渲染路径

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

假设场景中有N个物体,每个物体受M个光源的影响,那么要渲染整个场景一共需要N*M个Pass。可以看出,如果有大量逐像素光照,那么需要执行的Pass数目也会很大。因此,渲染引擎通常会限制每个物体的逐像素光照的数目。

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

Unity使用的判断规则如下:

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

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

前向渲染可以使用的内置光照变量
前向渲染可以使用的内置光照函数

顶点照明渲染路径

略过

延迟渲染路径

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

延迟渲染的缺点:

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

9.2 Unity的光源类型

Unity一共支持4种光源类型:平行光、点光源、聚光灯和面光源(area light)。面光源仅在烘焙时才可发挥作用,因此不在本节讨论范围内。

平行光

位置方向不变,没有衰减

点光源

位置方向均变化,有衰减

聚光灯

位置方向均变化,有衰减,照明区域为锥形

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
141
142
143
144
145
146
147
Shader "Unity Shaders Book/Chapter 9/Forward Rendering" {
Properties {
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Tags { "RenderType"="Opaque" }

Pass {
// Pass for ambient light & first pixel light (directional light)
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

// Apparently need to add this declaration
#pragma multi_compile_fwdbase

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.worldNormal = UnityObjectToWorldNormal(v.normal);

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

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

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

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

fixed atten = 1.0;

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

ENDCG
}

Pass {
// Pass for other pixel lights
Tags { "LightMode"="ForwardAdd" }

Blend One One

CGPROGRAM

// Apparently need to add this declaration
#pragma multi_compile_fwdadd

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.worldNormal = UnityObjectToWorldNormal(v.normal);

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

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
#ifdef USING_DIRECTIONAL_LIGHT
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif

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

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

#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

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

ENDCG
}
}
FallBack "Specular"

9.3 Unity的光照衰减

9.2代码中使用一张纹理作为查找表来在片元着色器中计算逐像素光照的衰减。这样的好处在于,计算衰减不依赖于数学公式的复杂性,我们只要使用一个参数值去纹理中采样即可。但使用纹理查找来计算衰减也有一些弊端。

  • 需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度。
  • 不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。

9.4 Unity的阴影

在实时渲染中,我们最常使用的是一种名为Shadow Map的技术。这种技术理解起来非常简单,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。而Unity就是使用的这种技术。

Unity选择使用一个额外的Pass来专门更新光源的阴影映射纹理,这个Pass就是LightMode标签被设置为ShadowCaster的Pass。这个Pass的渲染目标不是帧缓存,而是阴影映射纹理(或深度纹理)。Unity首先把摄像机放置到光源的位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。

传统的阴影映射纹理的实现

在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后,我们使用xy分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息。如果该深度值小于该顶点的深度值(通常由z分量得到),那么说明该点位于阴影中。

屏幕空间的阴影映射技术(Screenspace Shadow Map):

通过调用LightMode为ShadowCaster的Pass来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。然后,根据光源的阴影映射纹理和摄像机的深度纹理来得到屏幕空间的阴影图。如果摄像机的深度图中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然是可见的,但是却处于该光源的阴影中。通过这样的方式,阴影图就包含了屏幕空间中所有有阴影的区域。如果我们想要一个物体接收来自其他物体的阴影,只需要在Shader中对阴影图进行采样。由于阴影图是屏幕空间下的,因此,我们首先需要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。

使用阴影的两个过程:

  • 如果我们想要一个物体接收来自其他物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果。
  • 如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。在Unity中,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass来实现的。(如果使用了屏幕空间的投影映射技术,Unity还会使用这个Pass产生一张摄像机的深度纹理。)

Mesh Renderer组件的Cast Shadows和Receive Shadows属性可以控制该物体是否投射/接收阴影。

通过对9.2中的Shader做如下改造,使使用该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
//包含头文件
#include "AutoLight.cginc"
//顶点着色器输出添加宏SHADOW_COORDS,宏的参数需要是下一个可用的插值寄存器的索引值。声明一个用于对阴影纹理采样的坐标。
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
};
//在顶点着色器返回之前添加另一个内置宏TRANSFER_SHADOW,计算上一步中声明的阴影纹理坐标。
v2f vert(a2v v) {
v2f o;
...
// Pass shadow coordinates to pixel shader
TRANSFER_SHADOW(o);
return o;
}
//使用了一个内置宏SHADOW_ATTENUATION在片元着色器中计算阴影值
// Use shadow coordinates to sample shadow map
fixed shadow = SHADOW_ATTENUATION(i);
//最后只需要把阴影值shadow和漫反射以及高光反射颜色相乘即可。
/*
由于这些宏中会使用上下文变量来进行相关计算,例如TRANSFER_SHADOW会使用v.vertex或a.pos来计算坐标,因此为了能够让这些宏正确工作,我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。我们需要保证:a2v结构体中的顶点坐标变量名必须是vertex,顶点着色器的输出结构体v2f必须命名为v,且v2f中的顶点位置变量必须命名为pos。
*/

统一管理光照衰减和阴影

1
2
3
4
5
6
7
8
9
10
11
12
//前面顶点着色器中的操作和接受阴影相同
//UNITY_LIGHT_ATTENUATION是Unity内置的用于计算光照衰减和阴影的宏,它接受3个参数,它会将光照衰减和阴影值相乘后的结果存储到第一个参数中。
//Unity针对不同光源类型、是否启用cookie等不同情况声明了多个版本的UNITY_LIGHT_ATTENUATION。
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(ambient + (diffuse + specular) * atten, 1.0);
}

透明度测试的阴影

为了让使用透明度测试的物体得到正确的阴影效果,我们只需要在Unity Shader中更改一行代码,即把Fallback设置为Transparent/Cutout/VertexLit。但需要注意的是,由于Transparent/Cutout/VertexLit中计算透明度测试时,使用了名为_Cutoff的属性来进行透明度测试,因此,这要求我们的Shader中也必须提供名为_Cutoff的属性。否则,同样无法得到正确的阴影结果。

透明度混合的阴影

由于透明度混合需要关闭深度写入,由此带来的问题也影响了阴影的生成。总体来说,要想为这些半透明物体产生正确的阴影,需要在每个光源空间下仍然严格按照从后往前的顺序进行渲染,这会让阴影处理变得非常复杂,而且也会影响性能。因此,在Unity中,所有内置的半透明Shader是不会产生任何阴影效果的。当然,我们可以使用一些dirty trick来强制为半透明物体生成阴影,这可以通过把它们的Fallback设置为VertexLit、Diffuse这些不透明物体使用的Unity Shader,这样Unity就会在它的Fallback找到一个阴影投射的Pass。然后,我们可以通过物体的Mesh Renderer组件上的Cast Shadows和Receive Shadows选项来控制是否需要向其他物体投射或接收阴影。

9.5 本书使用的标准Unity Shader

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

第10章 高级纹理

10.1 立方体纹理

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

立方体纹理也仅可以反射环境,但不能反射使用了该立方体纹理的物体本身。想要得到令人信服的渲染结果,我们应该尽量对凸面体而不要对凹面体使用立方体纹理(因为凹面体会反射自身)。

立方体纹理在实时渲染中有很多应用,最常见的是用于天空盒子(Skybox)以及环境映射。

Skybox

创建一个Skybox材质,再把它赋在Window->Rendering->Lighting中。

保证渲染场景的摄像机的Camera组件中的Clear Flags被设置为Skybox。

在Unity中,天空盒子是在所有不透明物体之后渲染的,而其背后使用的网格是一个立方体或一个细分后的球体。

环境映射

在Unity 5中,创建用于环境映射的立方体纹理的方法有三种:第一种方法是直接由一些特殊布局的纹理创建;第二种方法是手动创建一个Cubemap资源,再把6张图赋给它;第三种方法是由脚本生成。

可以利用环境映射在物体表面模拟反射和折射。

折射

Snell's Law : \(η_1\sinθ_1=η_2\sinθ_2\)

入射光与折射光位于法线两侧,夹角为\(\theta\)\(η\)是介质的折射率。例如真空的折射率是1,而玻璃的折射率一般是1.5。

菲涅耳反射

Schlick菲涅耳近似等式: \[ F_{schlick}(v, n)=F_0+(1-F_0)(1-v\cdot n)^5 \] \(F_0\)是一个反射系数,用于控制菲涅耳反射的强度,v是视角方向,n是表面法线。

10.2 渲染纹理

现代的GPU允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(Render Target Texture, RTT),而不是传统的帧缓冲或后备缓冲(back buffer)。与之相关的是多重渲染目标(Multiple Render Target, MRT),这种技术指的是GPU允许我们把场景同时渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是使用多重渲染目标的一个应用。

在Unity中使用渲染纹理(Render Texture)通常有两种方式:

  • 在Project目录下创建一个渲染纹理,然后把某个摄像机的渲染目标设置成该渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中,而不会显示在屏幕上。使用这种方法,我们还可以选择渲染纹理的分辨率、滤波模式等纹理属性。
  • 在屏幕后处理时使用GrabPass命令或OnRenderImage函数来获取当前屏幕图像,Unity会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,下面我们可以在自定义的Pass中把它们当成普通的纹理来处理,从而实现各种屏幕特效。

镜子效果

使用一个渲染纹理作为输入属性,并把该渲染纹理在水平方向上翻转后直接显示到镜子上即可。

玻璃效果

通过一个Cubemap来模拟玻璃的反射,而在模拟折射时,则使用了GrabPass获取玻璃后面的屏幕图像,并使用切线空间下的法线对屏幕纹理坐标偏移后,再对屏幕图像进行采样来模拟近似的折射效果。

10.3 程序纹理

程序纹理(Procedural Texture)指的是那些由计算机生成的图像。

在Unity中,可以编写脚本自动生成纹理赋给材质。

也可以使用了名为Substance Designer的软件在Unity外部生成程序材质。很多3A的游戏项目都使用了由它生成的材质。我们可以从Unity的资源商店或网络中获取到很多免费或付费的Substance材质。这些材质都是以.sbsar为后缀的。新版本的Unity已经不内置支持。

第11章 让画面动起来

11.1 Unity Shader中的内置变量(时间篇)

Unity内置的时间变量

11.2 纹理动画

序列帧动画

连续播放一系列纹理来形成动画。可以将一系列纹理存入一张纹理图片,根据时间选择变化纹理坐标播放不同的纹理。

滚动的背景

根据时间在同一张纹理上进行滚动。还可以多张纹理叠加。

11.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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Shader "Unity Shaders Book/Chapter 11/Water" {
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
}
SubShader {
// Need to disable batching because of the vertex animation
//批处理会合并所有相关的模型,而这些模型各自的模型空间就会丢失。而在本例中,我们需要在物体的模型空间下对顶点位置进行偏移。因此,在这里需要取消对该Shader的批处理操作。
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}

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

ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
float _Magnitude;
float _Frequency;
float _InvWaveLength;
float _Speed;

struct a2v {
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};

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

v2f vert(a2v 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.pos = UnityObjectToClipPos(v.vertex + offset);

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

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed4 c = tex2D(_MainTex, i.uv);
c.rgb *= _Color.rgb;

return c;
}

ENDCG
}
}
FallBack "Transparent/VertexLit"
}

广告牌技术(Billboarding)

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

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
Shader "Unity Shaders Book/Chapter 11/Billboard" {
Properties {
_MainTex ("Main Tex", 2D) = "white" {}
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_VerticalBillboarding ("Vertical Restraints", Range(0, 1)) = 1
}
SubShader {
// Need to disable batching because of the vertex animation
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}

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

ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
fixed _VerticalBillboarding;

struct a2v {
float4 vertex : POSITION;
float4 texcoord : TEXCOORD0;
};

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

v2f vert (a2v v) {
v2f o;

// Suppose the center in object space is fixed
float3 center = float3(0, 0, 0);
float3 viewer = mul(unity_WorldToObject,float4(_WorldSpaceCameraPos, 1));

float3 normalDir = viewer - center;
// If _VerticalBillboarding equals 1, we use the desired view dir as the normal dir
// Which means the normal dir is fixed
// Or if _VerticalBillboarding equals 0, the y of normal is 0
// Which means the up dir is fixed
normalDir.y =normalDir.y * _VerticalBillboarding;
normalDir = normalize(normalDir);
// Get the approximate up dir
// If normal dir is already towards up, then the up dir is towards front
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));

// Use the three vectors to rotate the quad
float3 centerOffs = v.vertex.xyz - center;
float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z;

o.pos = UnityObjectToClipPos(float4(localPos, 1));
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);

return o;
}

fixed4 frag (v2f i) : SV_Target {
fixed4 c = tex2D (_MainTex, i.uv);
c.rgb *= _Color.rgb;

return c;
}

ENDCG
}
}
FallBack "Transparent/VertexLit"
}

如果需要对顶点动画添加阴影,需要提供自定义的ShadowCaster Pass。

第4篇 高级篇

第12章 屏幕后处理效果

屏幕后处理效果(screen post-processing effects)是游戏中实现屏幕特效的常见方法。

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

1
2
3
4
5
6
7
8
9
//当我们在脚本中声明此函数后,Unity会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。
MonoBehaviour.OnRenderImage (RenderTexture src, RenderTexture dest)
//在OnRenderImage函数中,我们通常是利用Graphics.Blit函数来完成对渲染纹理的处理。
/*
参数src对应了源纹理,在屏幕后处理技术中,这个参数通常就是当前屏幕的渲染纹理或是上一步处理后得到的渲染纹理。参数dest是目标渲染纹理,如果它的值为null就会直接将结果显示在屏幕上。参数mat是我们使用的材质,这个材质使用的Unity Shader将会进行各种屏幕后处理操作,而src纹理将会被传递给Shader中名为_MainTex的纹理属性。参数pass的默认值为-1,表示将会依次调用Shader内的所有Pass。否则,只会调用给定索引的Pass。
*/
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);

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

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

一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承自该基类,再实现派生类中不同的操作即可:

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

[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
public class PostEffectsBase : MonoBehaviour {

// Called when start
protected void CheckResources() {
bool isSupported = CheckSupport();

if (isSupported == false) {
NotSupported();
}
}

// Called in CheckResources to check support on this platform
protected bool CheckSupport() {
if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false) {
Debug.LogWarning("This platform does not support image effects or render textures.");
return false;
}

return true;
}

// Called when the platform doesn't support this effect
protected void NotSupported() {
enabled = false;
}

protected void Start() {
CheckResources();
}

// Called when need to create the material used by this effect
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material) {
if (shader == null) {
return null;
}

if (shader.isSupported && material && material.shader == shader)
return material;

if (!shader.isSupported) {
return null;
}
else {
material = new Material(shader);
//该对象不保存到场景。加载新场景时,也不会销毁它。
material.hideFlags = HideFlags.DontSave;
if (material)
return material;
else
return null;
}
}
}

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

继承上一节的PostEffectsBase编写脚本挂载在相机上,加载briSatConShader实现后处理。

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

public class BrightnessSaturationAndContrast : PostEffectsBase {

public Shader briSatConShader;
private Material briSatConMaterial;
public Material material {
get {
briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
return briSatConMaterial;
}
}

[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;

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);
}
}
}
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
Shader "Unity Shaders Book/Chapter 12/Brightness Saturation And Contrast" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Brightness ("Brightness", Float) = 1
_Saturation("Saturation", Float) = 1
_Contrast("Contrast", Float) = 1
}
SubShader {
Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

sampler2D _MainTex;
half _Brightness;
half _Saturation;
half _Contrast;

struct v2f {
float4 pos : SV_POSITION;
half2 uv: TEXCOORD0;
};

v2f vert(appdata_img v) {
v2f o;

o.pos = UnityObjectToClipPos(v.vertex);

o.uv = v.texcoord;

return o;
}

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);
}

ENDCG
}
}

Fallback Off
}

12.3 边缘检测

利用一些边缘检测算子对图像进行卷积(convolution)操作。

3种常见的边缘检测算子

12.4 高斯模糊

高斯模糊使用高斯核卷积。 \[ G(x,y)=\frac{1} {2\pi\sigma^2} e^{-\frac{x^2+y^2}{2\sigma^2} } \] σ是标准方差(一般取值为1), x和y分别对应了当前位置到卷积核中心的整数距离。

为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1。因此,高斯函数中e前面的系数实际不会对结果有任何影响。

12.5 Bloom效果

首先根据一个阈值提取出图像中的较亮区域,把它们存储在一张渲染纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果,最后再将其和原图像进行混合,得到最终的效果。

12.6 运动模糊

运动模糊的实现有多种方法。

一种实现方法是利用一块累积缓存(accumulation buffer)来混合多张连续的图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。

另一种应用广泛的方法是创建和使用速度缓存(velocity buffer),这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

第13章 使用深度和法线纹理

13.1 获取深度和法线纹理

在Unity中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的Pass渲染而得,这取决于使用的渲染路径和硬件。

  1. 通常来讲,当使用延迟渲染路径(包括遗留的延迟渲染路径)时,深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到G-buffer中。

  2. 而当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的Pass渲染而得的。具体实现是,Unity会使用着色器替换(Shader Replacement)技术选择那些渲染类型(即SubShader的RenderType标签)为Opaque的物体,判断它们使用的渲染队列是否小于等于2500(内置的Background、Geometry和AlphaTest渲染队列均在此范围内),如果满足条件,就把它渲染到深度和法线纹理中。因此,要想让物体能够出现在深度和法线纹理中,就必须在Shader中设置正确的RenderType标签。

在脚本中设置摄像机,在Shader中通过声明变量来访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C#
camera.depthTextureMode = DepthTextureMode.Depth;
//Shader
_CameraDepthTexture
//C#
camera.depthTextureMode = DepthTextureMode.DepthNormals;
//Shader
_CameraDepthNormalsTexture

//由纹理坐标对深度纹理进行采样
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
//i.scrPos是在顶点着色器中通过调用ComputeScreenPos(o.pos)得到的屏幕坐标。
float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.scrPos));
//输出线性深度值
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float linearDepth = Linear01Depth(depth);
return fixed4(linearDepth, linearDepth, linearDepth, 1.0);
//输出法线方向
fixed3 normal = DecodeViewNormalStereo(tex2D(_CameraDepthNormalsTexture, i.uv).xy);
return fixed4(normal * 0.5 + 0.5, 1.0);

13.2 再谈运动模糊

在C#端求得两个变换矩阵——前一帧的视角投影矩阵以及当前帧的视角投影矩阵的逆矩阵。

在Shader端的片元着色器中:

  1. 使用内置的SAMPLE_DEPTH_TEXTURE宏和纹理坐标对深度纹理进行采样,得到了深度值d,由d和纹理坐标映射回NDC((NDC下的xyz分量范围均为[-1,1])),得到NDC坐标(w设为1)。

  2. 用视角投影矩阵的逆矩阵左乘NDC坐标,整体除w得到世界坐标。(为什么将NDC坐标变换到世界坐标下需要除w

  3. 使用前一帧的视角投影矩阵左乘世界坐标,得到其在前一帧下的NDC坐标。

  4. 计算前一帧和当前帧在屏幕空间下的位置差,得到该像素的速度。

  5. 使用该速度值对它的邻域像素进行采样,相加后取平均值得到一个模糊的效果。

本节实现的运动模糊适用于场景静止、摄像机快速运动的情况,这是因为我们在计算时只考虑了摄像机的运动。因此,如果读者把本节中的代码应用到一个物体快速运动而摄像机静止的场景,会发现不会产生任何运动模糊效果。如果我们想要对快速移动的物体产生运动模糊的效果,就需要生成更加精确的速度映射图。读者可以在Unity自带的ImageEffect包中找到更多的运动模糊的实现方法。

13.3 全局雾效

Unity内置的雾效可以产生基于距离的线性或指数雾效。然而,要想在自己编写的顶点/片元着色器中实现这些雾效,我们需要在Shader中添加#pragma multi_compile_fog指令,同时还需要使用相关的内置宏,例如UNITY_FOG_COORDS、UNITY_TRANSFER_FOG和UNITY_APPLY_FOG等。这种方法的缺点在于,我们不仅需要为场景中所有物体添加相关的渲染代码,而且能够实现的效果也非常有限。当我们需要对雾效进行一些个性化操作时,例如使用基于高度的雾效等,仅仅使用Unity内置的雾效就变得不再可行。

本节基于屏幕后处理实现全局雾效。

基于屏幕后处理的全局雾效的关键是,根据深度纹理来重建每个像素在世界空间下的位置

  • 在上一节中,我们在模拟运动模糊时已经实现了这个要求,即构建出当前像素的NDC坐标,再通过当前摄像机的视角投影矩阵的逆矩阵来得到世界空间下的像素坐标,但是,这样的实现需要在片元着色器中进行矩阵乘法的操作,而这通常会影响游戏性能。
  • 在本节中,使用一个快速从深度纹理中重建世界坐标的方法。这种方法首先对图像空间下的视锥体射线(从摄像机出发,指向图像上的某点的射线)进行插值,这条射线存储了该像素在世界空间下到摄像机的方向信息。然后,我们把该射线和线性化后的视角空间下的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间下的位置。当我们得到世界坐标后,就可以轻松地使用各个公式来模拟全局雾效了。

13.4 再谈边缘检测

在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。

第14章 非真实感渲染

14.1 卡通风格的渲染

要实现卡通渲染有很多方法,其中之一就是使用基于色调的着色技术(tone-based shading)。

在实时渲染中,轮廓线渲染是应用非常广泛的一种效果。近20年来,有许多绘制模型轮廓线的方法被先后提出来。在《Real Time Rendering, third edition》一书中,作者把这些方法分成了5种类型。

  • 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息。这种方法简单快速,可以在一个Pass中就得到渲染结果,但局限性很大,很多模型渲染出来的描边效果都不尽如人意。

  • 过程式几何轮廓线渲染。这种方法的核心是使用两个Pass渲染。第一个Pass渲染背面的面片,并使用某些技术让它的轮廓可见;第二个Pass再正常渲染正面的面片。这种方法的优点在于快速有效,并且适用于绝大多数表面平滑的模型,但它的缺点是不适合类似于立方体这样平整的模型。

  • 基于图像处理的轮廓线渲染。我们在第12、13章介绍的边缘检测的方法就属于这个类别。这种方法的优点在于,可以适用于任何种类的模型。但它也有自身的局限所在,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。

  • 基于轮廓边检测的轮廓线渲染。上面提到的各种方法,一个最大的问题是,无法控制轮廓线的风格渲染。对于一些情况,我们希望可以渲染出独特风格的轮廓线,例如水墨风格等。为此,我们希望可以检测出精确的轮廓边,然后直接渲染它们。

  • 最后一个种类就是混合了上述的几种渲染方法。例如,首先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

这里使用第二种方法。

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
141
142
143
Shader "Unity Shaders Book/Chapter 14/Toon Shading" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
//控制漫反射色调的渐变纹理
_Ramp ("Ramp Texture", 2D) = "white" {}
//轮廓线宽度和颜色
_Outline ("Outline", Range(0, 1)) = 0.1
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
//计算高光反射时使用的阈值
_SpecularScale ("Specular Scale", Range(0, 0.1)) = 0.01
}
SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}

Pass {
NAME "OUTLINE"
Cull Front

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

float _Outline;
fixed4 _OutlineColor;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
};

v2f vert (a2v v) {
v2f o;

float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此来让背部轮廓线可见。
//在扩张背面顶点之前,我们首先对顶点法线的z分量进行处理,使它们等于一个定值,然后把法线归一化后再对顶点进行扩张。这样的好处在于,扩展后的背面更加扁平化,从而降低了遮挡正面面片的可能性。
normal.z = -0.5;
pos = pos + float4(normalize(normal), 0) * _Outline;
o.pos = mul(UNITY_MATRIX_P, pos);

return o;
}

float4 frag(v2f i) : SV_Target {
return float4(_OutlineColor.rgb, 1);
}

ENDCG
}

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

Cull Back

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#pragma multi_compile_fwdbase

#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
#include "UnityShaderVariables.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _Ramp;
fixed4 _Specular;
fixed _SpecularScale;

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;
}

float4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 worldHalfDir = 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, worldHalfDir);
//w是一个很小的值,当spec - threshold小于-w时,返回0,大于w时,返回1,否则在0到1之间进行插值。这样的效果是,我们可以在[-w, w]区间内,即高光区域的边界处,得到一个从0到1平滑变化的spec值,从而实现抗锯齿的目的。尽管我们可以把w设为一个很小的定值,但在本例中,我们选择使用邻域像素之间的近似导数值,这可以通过CG的fwidth函数来得到。
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);
}

ENDCG
}
}
FallBack "Diffuse"
}

14.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
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
///
/// Reference: Praun E, Hoppe H, Webb M, et al. Real-time hatching[C]
/// Proceedings of the 28th annual conference on Computer graphics and interactive techniques. ACM, 2001: 581.
///
Shader "Unity Shaders Book/Chapter 14/Hatching" {
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" {}
}

SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}

UsePass "Unity Shaders Book/Chapter 14/Toon Shading/OUTLINE"

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

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#pragma multi_compile_fwdbase

#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
#include "UnityShaderVariables.cginc"

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)
};

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;
}

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;
//通过从1中减去所有6张纹理的权重来得到纯白在渲染中的贡献度
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);
}

ENDCG
}
}
FallBack "Diffuse"
}

第15章 使用噪声

15.1 消融效果

消融(dissolve)效果常见于游戏中的角色死亡、地图烧毁等效果。在这些效果中,消融往往从不同的区域开始,并向看似随机的方向扩张,最后整个物体都将消失不见。

在C#端随时间更新材质参数——消融程度_BurnAmount。

在Shader端对噪声纹理进行采样,并将采样结果和用于控制消融程度的属性_BurnAmount相减,传递给clip函数。当结果小于0时,该像素将会被剔除,从而不会显示到屏幕上。如果通过了测试,则进行正常的光照计算。为了模拟烧焦效果,还要以_BurnAmount为参数插值多种颜色。

15.2 水波效果

使用GrabPass来获取当前屏幕的渲染纹理,并使用切线空间下的法线方向对像素的屏幕坐标进行偏移,再使用该坐标对渲染纹理进行屏幕采样,从而模拟近似的折射效果。

10.2.2节中的实现不同的是,水波的法线纹理是由一张噪声纹理生成而得,而且会随着时间变化不断平移,模拟波光粼粼的效果。除此之外,我们没有使用一个定值来混合反射和折射颜色,而是使用之前提到的菲涅耳系数来动态决定混合系数。

15.3 再谈全局雾效

相比13.3节的全局雾效实现,在Shader的片元着色器中对高度的计算添加了噪声的影响,实现非均匀雾效。

15.4 扩展阅读

这些噪声纹理都是如何构建出来的?这些噪声纹理可以被认为是一种程序纹理(Procedure Texture),它们都是由计算机利用某些算法生成的。Perlin噪声Worley噪声是两种最常使用的噪声类型,例如我们在15.3节中使用的噪声纹理由Perlin噪声生成而来。Perlin噪声可以用于生成更自然的噪声纹理,而Worley噪声则通常用于模拟诸如石头、水、纸张等多孔噪声。

第16章 Unity中的渲染优化技术

16.1 移动平台的特点

移动设备上的GPU架构专注于尽可能使用更小的带宽和功能。

16.2 影响性能的因素

  1. CPU
    • 过多的draw call。(使用批处理技术减少draw call数目。)
    • 复杂的脚本或者物理模拟。
  2. GPU
    • 顶点处理。(减少需要处理的顶点数目。化几何体; 使用模型的LOD(Level of Detail)技术; 使用遮挡剔除(Occlusion Culling)技术。)
      • 过多的顶点。
      • 过多的逐顶点计算。
    • 片元处理。(减少需要处理的片元数目。控制绘制顺序; 警惕透明物体;减少实时光照。)
      • 过多的片元(既可能是由于分辨率造成的,也可能是由于overdraw造成的)。
      • 过多的逐片元计算。
  3. 带宽
    • 使用了尺寸很大且未压缩的纹理。(减少纹理大小。)
    • 分辨率过高的帧缓存。(利用分辨率缩放。)

16.3 Unity中的渲染分析工具

渲染统计窗口(Rendering Statistics Window)

Game -> Stats

性能分析器(Profiler)

Window -> Analysis -> Profiler

帧调试器(Frame Debugger)

Window -> Analysis -> Frame Debugger

16.4 减少draw call数目

批处理的思想很简单,就是在每次面对draw call时尽可能多地处理多个物体。

Unity中支持两种批处理方式:一种是动态批处理,另一种是静态批处理。

对于动态批处理来说,优点是一切处理都是Unity自动完成的,不需要我们自己做任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就会破坏了这种机制,导致Unity无法动态批处理一些使用了相同材质的物体。

而对于静态批处理来说,它的优点是自由度很高,限制很少;但缺点是可能会占用更多的内存,而且经过静态批处理后的所有物体都不可以再移动了(即便在脚本中尝试改变物体的位置也是无效的)。静态批处理的实现非常简单,只需要把物体面板上的Static复选框勾选上即可(实际上我们只需要勾选Batching Static即可)。

如果两个材质之间只有使用的纹理不同,我们可以把这些纹理合并到一张更大的纹理中,这张更大的纹理被称为是一张图集(atlas)。一旦使用了同一张纹理,我们就可以使用同一个材质,再使用不同的采样坐标对纹理采样即可。

16.5 减少需要处理的顶点数目

优化几何体

Unity中显示的数目往往要多于建模软件里显示的顶点数:

三维软件更多地是站在我们人类的角度理解顶点的,即组成几何体的每一个点就是一个单独的点。而Unity是站在GPU的角度上去计算顶点数的。在GPU看来,有时需要把一个顶点拆分成两个或更多的顶点。这种将顶点一分为多的原因主要有两个:一个是为了分离纹理坐标(uv splits),另一个是为了产生平滑的边界(smoothing splits)。它们的本质,其实都是因为对于GPU来说,顶点的每一个属性和顶点之间必须是一对一的关系。而分离纹理坐标,是因为建模时一个顶点的纹理坐标有多个。例如,对于一个立方体,它的6个面之间虽然使用了一些相同的顶点,但在不同面上,同一个顶点的纹理坐标可能并不相同。对于GPU来说,这是不可理解的,因此,它必须把这个顶点拆分成多个具有不同纹理坐标的顶点。平滑边界也是类似的,不同的是,此时一个顶点可能会对应多个法线信息或切线信息。这通常是因为我们要决定一个边是一条硬边(hard edge)还是一条平滑边(smooth edge)。

LOD技术

在Unity中,我们可以使用LOD Group组件来为一个物体构建一个LOD。我们需要为同一个对象准备多个包含不同细节程序的模型,然后把它们赋给LOD Group组件中的不同等级,Unity就会自动判断当前位置上需要使用哪个等级的模型。

遮挡剔除技术

需要把遮挡剔除和摄像机的视锥体剔除(Frustum Culling)区分开来。视锥体剔除只会剔除掉那些不在摄像机的视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住。而遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在可见的对象集合的层级结构。在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而哪些被其他物体挡住不可见。使用遮挡剔除技术,不仅可以减少处理的顶点数目,还可以减少overdraw,提高游戏性能。

16.6 减少需要处理的片元数目

这部分优化的重点在于减少overdraw。简单来说,overdraw指的就是同一个像素被绘制了多次。

控制绘制顺序

在Unity中,那些渲染队列数目小于2500(如“Background”“Geometry”和“AlphaTest”)的对象都被认为是不透明(opaque)的物体,这些物体总体上是从前往后绘制的,而使用其他的队列(如“Transparent”“Overlay”等)的物体,则是从后往前绘制的。这意味着,我们可以尽可能地把物体的队列设置为不透明物体的渲染队列,而尽量避免使用半透明队列。而且,我们还可以充分利用Unity的渲染队列来控制绘制顺序。

时刻警惕透明物体

减少实时光照和阴影

16.7 节省带宽

减少纹理大小

多级渐远纹理技术(mipmapping)和纹理压缩

利用分辨率缩放

尤其是对于很多低端手机,除了分辨率高其他硬件条件并不尽如人意。

16.8 减少计算复杂度

Shader的LOD技术

它的原理是,只有Shader的LOD值小于某个设定的值,这个Shader才会被使用,而使用了那些超过设定值的Shader的物体将不会被渲染。

代码方面的优化

根据硬件条件进行缩放

第5篇 扩展篇

第17章 Unity的表面着色器探秘

17.1 表面着色器的一个例子

和顶点/片元着色器需要包含到一个特定的Pass块中不同,表面着色器的CG代码是直接而且也必须写在SubShader块中,Unity会在背后为我们生成多个Pass。当然,可以在SubShader一开始处使用Tags来设置该表面着色器使用的标签。

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"
}

17.2 编译指令

1
#pragma  surface  surfaceFunction  lightModel  [optionalparams]

#pragma surface用于指明该编译指令是用于定义表面着色器的,在它的后面需要指明使用的表面函数(surfaceFunction)和光照模型(lightModel),同时,还可以使用一些可选参数来控制表面着色器的一些行为。

17.3 两个结构体

一个表面着色器需要使用两个结构体:表面函数的输入结构体Input,以及存储了表面属性的结构体SurfaceOutput(Unity 5新引入了另外两个同种的结构体SurfaceOutputStandard和SurfaceOutputStandardSpecular)。

17.4 Unity背后做了什么

Unity实际会在背后为表面着色器生成真正的顶点/片元着色器。在每个编译完成的表面着色器的面板上,都有一个“Show generated code”的按钮可供查看。

17.6 Surface Shader的缺点

第18章 基于物理的渲染

基于物理的渲染技术(Physically Based Shading, PBS)已经应用于实时渲染中。Unity 5引入了一个名为Standard Shader的可在不同材质之间通用的着色器,而该着色器就是使用了基于物理的光照模型。

18.1 PBS的理论和数学基础

本节主要参考了Naty Hoffman在SIGGRAPH 2013上做的名为Background: Physics and Math of Shading的演讲。

菲涅耳等式(Fresnel equations)描述有多少百分比的光会被反射(另一部分就是被折射了):

菲涅耳等式

物体的表面和光照发生的各种行为,更像是一系列微小的光学平滑平面和光交互的结果,其中每个小平面会把光分割成不同的方向。光滑表面的微平面的法线变化较小,反射光线的方向变化也更小。粗糙表面的微平面的法线变化较大,反射光线的方向变化也更大

金属材质具有很高的吸收系数,因此,所有被折射的光往往会被立刻吸收,被金属内部的自由电子转化成其他形式的能量。而非金属材质则会同时表现出吸收和散射两种现象,这些被散射出去的光又被称为次表面散射光(subsurface-scattered light)。

BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数):

\(f(l, v)\)(单位\(sr^{-1}\)),即反射辐射率(单位\(W/m^2sr\))和入射辐照度(单位\(W/m^2\))之比。\(l\)是入射光方向,\(v\)是观察方向。

反射方程(reflection equation): \[ L_o(v)=\int_{\Omega}f(l,v)\times L_i(l)(n\cdot l)d\omega_i \] 即给定观察方向\(v\),该方向上的反射辐射率\(L_o(v)\)等于所有入射方向的辐照度乘以BRDF值的积分。推导过程可以看这篇文章

对于方向确定、大小为无线小的精确光源(punctual light sources),使用\(l_c\)来表示它的方向,使用\(c_{light}\)表示它的颜色,反射等式可以简化为: \[ L_o(v)=\pi f(l_c,v)\times c_{light} (n\cdot l_c) \] 如果场景中包含了多个精确光源,我们可以把它们分别代入上面的式子进行计算,然后把它们的结果相加即可。公式推导参考[1]。

BRDF满足交换律和能量守恒。交换率即交换\(l\)\(v\),BRDF值不变。能量守恒即表面反射的能量不能超过入射的光能。

BRDF中用于描述表面反射的部分被称为高光反射项(specular term),以及用于描述次表面散射的漫反射项(diffuse term)。

高光反射和漫反射

漫反射项

Lambert模型就是最简单、也是应用最广泛的漫反射BRDF。准确的Lambertian BRDF的表示为: \[ f_{Lambsrt}(l,v)=\frac{c_{diff}}{\pi} \] \(c_{diff}\)表示漫反射光线所占的比例,它也通常被称为是漫反射颜色(diffuse color)。与我们之前讲过的Lambert光照模型不太一样的是,上面的式子实际上是一个定值,我们常见到的余弦(即\(n\cdot l\))因子部分实际是反射等式的一部分,而不是BRDF的部分。上面的式子之所以要除以π,是因为我们假设漫反射在所有方向上的强度都是相同的,而BRDF要求在半球内的积分值为1(为什么BRDF的漫反射项要除以π)。

给定入射方向\(l\)的光源在表面某点的漫反射辐射率为: \[ L_{diff}=\frac{c_{diff}}{\pi}\times L_i(l)(n\cdot l) \] Disney使用的BRDF[2]更复杂。

高光反射项

基于Torrance-Sparrow microfacet model[5],BRDF的高光反射项可以用下面的形式来表示: \[ f_{spec}(l,v)=\frac{F(l,h)G(l,v,h)D(h)}{4(n\cdot l)(n\cdot v)} \] \(h\)\(l\)\(v\)的半程向量。

\(D(h)\)是微面元的法线分布函数(normal distribution function, NDF),它用于计算有多少比例的微面元的法线满足\(m=h\),只有这部分微面元才会把光线从\(l\)方向反射到\(v\)上。

\(G(l, v, h)\)是阴影——遮掩函数(shadowing-masking function),它用于计算那些满足\(m=h\)的微面元中有多少会由于遮挡而不会被人眼看到,因此它给出了活跃的微面元(active microfacets)所占的浓度,只有活跃的微面元才会成功地把光线反射到观察方向上。

\(F(l, h)\)则是这些活跃微面元的菲涅尔反射(Fresnel reflectance)函数,它可以告诉我们每个活跃的微面元会把多少入射光线反射到观察方向上,即表示了反射光线占入射光线的比率。

分母\(4(n\cdot l)(n\cdot v)\)是用于校正从微面元的局部空间到整体宏观表面数量差异的校正因子。

Blinn-Phong模型[7]使用的法线分布函数\(D_{blinn}(h)=(n\cdot h)^{gloss}\)

Unity中的PBS实现

Unity 5一共实现了两种PBS模型。一种是基于GGX模型的,另一种则是基于归一化的Blinn—Phong模型的。这两种模型使用了不同的公式来计算高光反射项中的法线分布函数\(D(h)\)和阴影—遮掩函数\(G(l,v,h)\)。在默认情况下,Unity 5使用基于归一化后的Blinn-Phong模型来实现基于物理的渲染(尽管很多引擎选择使用GGX模型)。

我们可以在Unity内置的UnityStandardBRDF.cginc文件中找到它的实现。

18.2 Unity 5的Standard Shader

Unity支持两种流行的基于物理的工作流程:金属工作流(Metallic workflow)和高光反射工作流(Specular workflow)。

金属工作流是默认的工作流程,对应的Shader为Standard Shader。而如果想要使用高光反射工作流,就需要在材质的Shader下拉框中选择Standard(Specular setup)。需要注意的是,通常来讲,使用不同的工作流可以实现相同的效果,只是它们使用的参数不同而已。金属工作流也不意味着它只能模拟金属类型的材质,金属工作流的名字来源于它定义了材质表面的金属值(是金属类型的还是非金属类型的)。高光反射工作流的名字来源于它可以直接指定表面的高光反射颜色(有很强的高光反射还是很弱的高光反射)等,而在金属工作流中这个颜色需要由漫反射颜色和金属值衍生而来。在实际的游戏制作过程中,我们可以选择自己更偏好的工作流来制作场景,这更多的是个人喜好的问题。当然也可以同时混用两种工作流。

材质和光的交互可以分成漫反射和高光反射两个部分,其中漫反射对应了次表面散射的结果,而高光反射则对应了表面反射的结果。

金属材质

  • 几乎没有漫反射,因为所有被吸收的光都会被自由电子立刻转化为其他形式的能量;
  • 有非常强烈的高光反射;
  • 高光反射通常是有颜色的,例如金子的反光颜色为黄色。

非金属材质

  • 大多数角度高光反射的强度比较弱,但在掠射角时高光反射强度反而会增强,即菲涅耳现象;
  • 高光反射的颜色比较单一;
  • 漫反射的颜色多种多样。

读者需要在Edit →Project Setttings→Player→Other Settings→Color Space中选择Linear才可以得到和图18.9中相同的效果,这是因为基于物理的渲染需要使用线性空间(详见18.3.4节)来进行相关计算。

使用Standard Shader

金属工作流:

Albedo:物体的整体颜色

Metallic:0-1 1金属

Smoothness:0-1 1光滑

如果我们在设置Metallic属性时使用的是一张纹理,那么这张纹理的A通道就对应了表面的Smoothness值(此时纹理的GB通道则被忽略)。

高光反射工作流:

Albedo:定义了表面的漫反射强度。对于非金属材质,它的值通常仍然是视觉上认为的物体颜色,但对于金属材质,Albedo的值通常非常接近黑色(金属材质几乎不存在次表面散射的现象)。

Specular:非金属材质通常使用一个灰度值范围在0~55的深灰色来作为Specular值,表明非金属材质的高光反射较弱。金属材质则通常会使用视觉上认为的该金属的颜色作为它的Specular值。

Smoothness:同上

材质面板的Render Mode选项: Standard Shader支持4种渲染模式,分别是Opaque、Cutout、Fade和Transparent。

  • Opaque用于渲染最常见的不透明物体,这也是默认的渲染模式。
  • 对于像玻璃这样的材质,我们可以选择Transparent模式,在这个渲染模式下,Albedo属性的A通道用于控制材质的透明度。
  • 而在Cutout渲染模式下,Albedo属性中纹理的A通道会成为一个掩码纹理,而它的子属性Alpha Cutoff将是透明度测试时使用的阈值。
  • Fade模式和Transparent模式是类似的,不同的是,在Transparent模式下,当材质的透明值不断降低时,它的反射仍然能被保留,而在Fade模式下,该材质的所有渲染效果都会逐渐从屏幕上淡出。

想要让整个场景的渲染结果令人满意,尤其包含了复杂光照的场景,仅仅有这些使用了PBS的材质是不够的,我们需要使用Unity提供的其他一些重要的技术,例如HDR格式的Skybox、全局光照、反射探针、光照探针、HDR和屏幕后处理等。

18.3 一个更加复杂的例子

反射探针

当关闭场景中的所有光源并把环境光照强度设为0后,使用了Standard Shader的物体仍然具有光照效果,只有在 Lighting->Environment->Environment Reflections 把反射源设置为空,并关闭所有反射探针,才能使得物体不接受任何默认的反射信息。

反射探针(Reflection Probes)的工作原理和光照探针(Light Probes)类似,它允许我们在场景中的特定位置上对整个场景的环境反射进行采样,并把采样结果存储在每个探针上。当游戏中包含反射效果的物体从这些探针附近经过时,Unity会把从这些邻近探针存储的反射结果传递给物体使用的反射纹理。如果物体周围存在多个反射探针,Unity还会在这些反射结果之间进行插值,来得到平滑渐变的反射效果。实际上,Unity会在场景中放置一个默认的反射探针,这个反射探针存储了对场景使用的Skybox的反射结果,来作为场景的环境光照。如果我们需要让场景中的物体包含额外的反射效果,就需要放置更多的反射探针。

Baked,这种类型的反射探针是通过提前烘焙来得到该位置使用的Cubemap的,在游戏运行时反射探针中存储的Cubemap并不会发生变化。需要注意的是,这种类型的反射探针在烘焙时同样只会处理那些静态物体(即那些被标识为Reflection Probe Static的物体);

Realtime,这种类型则会实时更新当前的Cubemap,并且不受静态物体还是动态物体的影响。当然,这种类型的反射探针需要花费更多的处理时间,因此,在使用时应当非常小心它们的性能。幸运的是,Unity允许我们从脚本中通过触发来精确控制反射探针的更新;

Custom,这种类型的探针既可以让我们从编辑器中烘焙它,也可以让我们使用一个自定义的Cubemap来作为反射映射,但自定义的Cubemap不会被实时更新。

全局光照

除了Standard Shader外,Unity还引入了一个重要的流水线——实时全局光照(Global Illumination, GI)流水线。使用GI,场景中的物体不仅可以受直接光照的影响,还可以接受间接光照的影响。

线性空间

基于物理的渲染需要使用线性空间来进行相关计算。

亮度上的线性变化对人眼感知来说是非均匀的,人眼更容易感知暗部区域的变换,而对较亮区域的变化比较不敏感。

摄影设备如果使用了8位空间来存储照片的话,会使用大约为0.45的编码伽马来对输入的亮度进行编码,得到一张编码后的图像。因此,图像中0.5像素值对应的亮度其实并不是0.5,而大约为0.22(\(0.5\approx 0.22^{0.45}\))。

当把图片放到显示器里显示时,我们应该对图像再进行一次解码操作,使得屏幕输出的亮度和捕捉到的亮度是符合线性的。

编码伽马和显示伽马

微软联合爱普生、惠普提供了sRGB颜色空间标准,推荐显示器的显示伽马值为2.2,并配合0.45的编码伽马就可以保证最后伽马曲线之间可以相互抵消(因为\(2.2\times 0.45\approx1\))。绝大多数的摄像机、PC和打印机都使用了上述的sRGB标准。

当我们选择伽马空间时,实际上就是“放任模式”,不会对Shader的输入进行任何处理,即使输入可能是非线性的;也不会对输出像素进行任何处理,这意味着输出的像素会经过显示器的显示伽马转换后得到非预期的亮度,通常表现为整个场景会比较昏暗。当选择线性空间时,Unity会把输入纹理设置为sRGB模式,在这种模式下,硬件在对纹理进行采样时会自动将其转换到线性空间中;并且,GPU会在Shader写入颜色缓冲前自动进行伽马校正或是保持线性在后面进行伽马校正,这取决于当前的渲染配置。

如果有一天我们对图像的存储空间能够大大提升,通用的格式不再是8位时,例如是32位时,伽马也许就会消失。因为,我们有足够多的颜色空间可以利用,不需要为了充分利用存储空间进行伽马编码

HDR ( High Dynamic Range )

Nvidia曾总结过使用HDR进行渲染的动机:让亮的物体可以真地非常亮,暗的物体可以真地非常暗,同时又可以看到两者之间的细节。

HDR使用远远高于8位的精度(如32位)来记录亮度信息,使得我们可以表示超过0~1内的亮度值,从而可以更加精确地反映真实的光照环境。尽管最后我们仍然需要把它们转换到LDR进行显示,但我们可以使用色调映射(tonemapping)技术来控制这个转换的过程,从而允许我们最大限度地保留需要的亮度细节。

PBS优点

PBS并不意味着游戏画面需要追求和照片一样真实的效果。事实上,很多游戏都不需要刻意去追求与照片一样的真实感,玩家眼中的真实感大多也并不是如此。PBS的优点在于,我们只需要一个万能的shader就可以渲染相当一大部分类型的材质,而不是使用传统的做法为每种材质写一个特定的shader。同时,PBS可以保证在各种光照条件下,材质都可以自然地和光源进行交互,而不需要我们反复地调整材质参数。

18.6 参考文献

[1] Hoffman N. Background: physics and math of shading[C]//Fourth International Conference and Exhibition on Computer Graphics and Interactive Techniques, Anaheim, USA. 2013: 21-25。

[2] Burley B, Studios W D A. Physically-based shading at disney[C]//ACM SIGGRAPH. 2012:1-7。

[3] Walter B, Marschner S R, Li H, et al. Microfacet models for refraction through rough surfaces[C]//Proceedings of the 18th Eurographics conference on Rendering Techniques. Eurographics Association, 2007: 195-206。

[4] Beckmann P, Spizzichino A. The scattering of electromagnetic waves from rough surfaces[J]. Norwood, MA, Artech House, Inc., 1987, 511 p.,1987, 1。

[5] Torrance K E, Sparrow E M. Theory for off-specular reflection from roughened surfaces[J]. JOSA, 1967, 57(9): 1105-1112。

[6] Smith B G. Geometrical shadowing of a random rough surface[J].Antennas and Propagation, IEEE Transactions on, 1967, 15(5): 668-671。

[7] Blinn J F. Models of light reflection for computer synthesized pictures[C]//ACM SIGGRAPH Computer Graphics. ACM, 1977, 11(2): 192-198。

[8] Schlick C. An inexpensive BRDF model for physically-based rendering[C]//Computer graphics forum. 1994, 13(3): 233-246。

第19章 Unity 5更新了什么

第20章 还有更多内容吗

注意点

mul()函数一般使用内置矩阵左乘列向量,等价于内置矩阵的转置右乘行向量。

获取指向相机的方向:

fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));

获取指向光源的方向:

fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

计算时辐射率就是颜色。

使用fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));从法线图中解压得到法线向量,然后由xy反推z,因为xyz是模为1的方向向量。


《Unity Shader入门精要》笔记
https://reddish.fun/posts/Notebook/Unity-Shader-note/
作者
bit704
发布于
2023年10月1日
许可协议