Unity 渲染教程(二):着色器基础

时间:2024-04-04 10:07:04

原文出处:http://gad.qq.com/program/translateview/7173930


这是关于渲染基础的系列教程的第二部分。这个渲染基础的系列教程的第一部分是有关矩阵的内容。在这篇文章中我们将编写我们的第一个着色器代码并导入纹理。


这个系列教程是使用Unity 5.4.0开发的,这个版本目前还是开放测试版本。我使用的是build 5.4.0b10版本。

Unity 渲染教程(二):着色器基础
对球使用纹理。


1. 默认的场景

当你在Unity中创建新的场景的时候,你将使用默认的相机和定向的光源。  通过GameObject / 3D Object / Sphere这个菜单项来创建一个简单的球体,然后把这个简单的球体放在原点,并把相机放在它的前面。

Unity 渲染教程(二):着色器基础
默认场景中的默认球体。

这是一个非常简单的场景,但是已经有很多复杂的渲染内容了。为了更好地抓住渲染过程,摆脱所有奇怪的东西是非常有帮助的,首先让我们只关心渲染的基础部分。


1.1 剥离那些和渲染无关的内容

通过菜单项Window / Lighting来查看场景的光照设置。 这将打开一个具有三个选项卡的光照窗口。我们只对默认情况下处于**状态的“场景”选项卡感兴趣。

Unity 渲染教程(二):着色器基础
默认的光照设置。

这是一个关于环境光照的部分,你可以在其中选择天空盒。这个天空盒目前用于场景背景、环境光照和反射。让我们将其设置为none进行关闭。

在你进行设置的时候,你还可以关闭预计算和实时全局光照的面板。我们不会很快使用到这些东西。

Unity 渲染教程(二):着色器基础
不再使用天空盒了。

在没有天空盒的情况下,环境光源会自动切换为纯色。环境光源的默认颜色为深灰色,具有非常浅的蓝色色调。而反射变为纯黑色,如警告框所示。

正如你可能期望的那样,球体会变得更暗,背景会是纯色。但是,得到的结果却是背景是深蓝色。那么这个颜色来自哪里?

Unity 渲染教程(二):着色器基础
简化后的光照

背景颜色是根据摄像机来定义的。它在默认情况下会渲染天空盒,但是它也会回落到纯色状态。

Unity 渲染教程(二):着色器基础
默认的相机设置。

 为什么背景颜色的透明通道值为5而不是255?

要进一步简化渲染的话,请取消方向光源对象的**或将其删除。这将摆脱场景中的直接光照,以及由直接光照所投射的阴影。剩下的就是背景,会用环境颜色显示球体的轮廓。

Unity 渲染教程(二):着色器基础
球体处于黑暗之中。



2. 从物体到二维图像

我们这个非常简单的场景是用两个步骤绘制出来的。 首先,图像用相机的背景颜色进行填充。然后将我们的球体的轮廓绘制在填充颜色的上面。

Unity怎么知道它必须画一个球体? 我们有一个球体对象,这个对象有一个网格渲染器组件。如果此对象位于相机的视图内,那么就应该出现在最终的图像中。 Unity通过检查对象的包围盒否与相机的视锥体相交来验证这一点。


Unity 渲染教程(二):着色器基础
默认的球体。

变换组件用于改变网格和包围盒的位置、方向和大小。实际上,整个变换层次都会被用到,正如第1部分“矩阵”中所描述的那样。如果对象会出现在相机的视图中,则这个物体会被安排进行渲染。

最后,图形处理器负责渲染对象的网格。 特定的渲染指令由对象的材质定义。 材质引用了着色器 - 这是一个图形处理器程序,加上它可能有的任何设置。

Unity 渲染教程(二):着色器基础
每个组件控制着渲染哪些内容。

我们的对象目前有默认材质,它使用Unity的标准着色器。我们要用我们自己的着色器来代替它,我们将从头开始构建它。



2.1 你的第一个着色器程序

通过Assets / Create / Shader / Unlit Shader创建一个新的着色器,并且将它命名为类似My First Shader这样的名字。

Unity 渲染教程(二):着色器基础
你的第一个着色器程序。

打开着色器文件并删除其内容,所以我们可以从头开始。

着色器代码用Shader关键字定义。它后面是一个字符串,描述可用于选择此着色器的着色器菜单项。它不需要匹配文件名。然后跟着的是填充了着色器内容的块。
1
2
3
Shader "Custom/My First Shader" {
 
}
保存文件。 你将收到不支持这个着色器的警告,因为它没有子着色器或是备选着色器。这是因为它是空的缘故。

虽然这个着色器没有什么功能,但是我们可以将它分配给一个材质。因此,通过Assets / Create / Material来创建一个新材质,并从着色器菜单中选择我们的着色器。

Unity 渲染教程(二):着色器基础Unity 渲染教程(二):着色器基础
使用了你的着色器的材质。

更改我们的球体对象,使我们的球体对象使用我们自己的材质,而不是默认材质。 球体将变为洋红色。发生这种情况是因为Unity会切换到一个错误的着色器,它使用这种颜色来引起你对问题的注意。

Unity 渲染教程(二):着色器基础Unity 渲染教程(二):着色器基础
使用了你的着色器的材质。

着色器的错误提示信息中提到子着色器。你可以使用这些子着色器将多个着色器变量组合在一起。这允许你为不同的构建平台或者LOD值不同的情况下提供不同的子着色器。让我们举个简单的例子来说,你可以为桌面电脑上运行的那个应用使用一个子着色器,而为移动设备上运行的应用使用另一个子着色器。
1
2
3
4
5
6
Shader "Custom/My First Shader" {
 
    SubShader {
         
    }
}
子着色器里面必须包含至少一个通道。着色器通道是对象实际被渲染的地方。 我们将使用一个通道,但着色器里面可以有多个。具有多个通道意味着对象被多次渲染,这是很多效果所需要的。
1
2
3
4
5
6
7
8
9
Shader "Custom/My First Shader" {
 
    SubShader {
 
        Pass {
 
        }
    }
}
我们的球体现在可能变成白色,因为我们使用的是一个空通道的默认行为。如果发生这种情况,这意味着我们不再收到任何着色器错误的提示信息。但是,你可能仍然在控制台中看到旧的错误提示信息。编辑器倾向于坚持提示错误信息,因为当着色器重新编译而没有错误的时候,这些错误提示信息是不会被清除的。

Unity 渲染教程(二):着色器基础
一个白色的球体。



2.2 着色器程序

现在是时候来编写我们自己的着色器程序了。我们用Unity的着色语言来做这个功能,这是HLSL和CG着色语言的变体。我们必须用CGPROGRAM关键字指示我们的代码开始。我们必须以ENDCG关键字来指示我们的代码终止。
1
2
3
4
5
Pass {
    CGPROGRAM
 
    ENDCG
}

着色器编译器现在会发出警告,警告我们的着色器里面没有顶点程序和片段程序。着色器由两个程序组成,也就是顶点程序和片段程序。顶点程序负责处理网格的顶点数据。 这包括从对象空间到显示空间的转换,就像我们在第1部分“矩阵”中所做的那样。片段程序负责对位于网格三角形内的单个像素进行渲染。

Unity 渲染教程(二):着色器基础
顶点程序和片段程序。

 我们必须通过pragma指令告诉编译器使用哪些程序。
1
2
3
4
5
6
CGPROGRAM
 
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
 
ENDCG

编译器会再次发出警告,这次因为它找不到我们指定的程序。这是因为我们还没有定义这些程序的缘故。

定义顶点程序和片段程序就像定义方法一样,非常像C#里面的做法,虽然它们通常被称为函数。让我们简单地创建两个空的返回void的方法,并给它们适当的名称。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CGPROGRAM
 
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
 
void MyVertexProgram () {
 
}
 
void MyFragmentProgram () {
 
}
 
ENDCG
此时,着色器将编译,球体将消失。或者你仍然会得到错误信息提示。这取决于你的编辑器使用的是哪个渲染平台。如果你使用的是Direct3D 9渲染平台,你可能会得到错误信息提示。



2.3 着色器的编译

Unity的着色器编译器接受我们的代码,并将其转换为不同的程序,具体如何转换取决于目标平台。不同的平台需要不同的解决方案。 例如,如果是Windows 平台的话,需要的是Direct3D,如果是Mac平台的话,需要的是OpenGL ,如果是移动平台的话,需要的是OpenGL ES,等等。 我们不是在这里处理单个编译器,而是处理多个编译器。

你最终使用哪个编译器取决于你的定位。由于这些编译器并不相同,因此每个平台可能会产生不同的结果。举个简单的例子来说,我们的空程序使用OpenGL和Direct3D 11的话,就能正常工作,但在如果使用的是Direct3D 9,就会失败。

在编辑器中选择着色器,并查看检查器窗口。它会显示有关着色器的一些信息,包括当前的编译器错误。还有一个带有“编译和显示代码”按钮和下拉菜单的“编译代码”项。 如果单击“编译和显示代码”按钮,Unity将编译着色器代码并在编辑器中打开着色器代码的输出,因此你可以检查生成的代码具体是什么。

Unity 渲染教程(二):着色器基础
着色器检查器,会显示在所有平台上的错误信息。

 你可以通过下拉菜单手动选择编译着色器的平台。默认情况下是使用编辑器所使用的图形设备进行编译。你可以手动选择其他平台进行编译,无论是你当前的构建平台,还是你有许可证的所有平台,或是其他的自定义选择。这使你能够快速确保你的着色器在多个平台上能够正常的编译,而不必进行完整的构建。

Unity 渲染教程(二):着色器基础
选择OpenGLCore。

要编译所选的程序,请关闭弹出窗口,然后单击“编译并显示代码”按钮。单击弹出窗口中的小的“显示”按钮将显示使用的着色器变量,但是这在现在没有用。

举个简单的例子来说明,这里当我们的着色器是为OpenGlCore平台编译的时候得到的代码。
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
// Compiled shader for custom platforms, uncompressed size: 0.5KB
 
// Skipping shader variants that would not be included into build of current scene.
 
Shader "Custom/My First Shader" {
SubShader {
 Pass {
  GpuProgramID 16807
Program "vp" {
SubProgram "glcore " {
"#ifdef VERTEX
#version 150
#extension GL_ARB_explicit_attrib_location : require
#extension GL_ARB_shader_bit_encoding : enable
void main()
{
    return;
}
#endif
#ifdef FRAGMENT
#version 150
#extension GL_ARB_explicit_attrib_location : require
#extension GL_ARB_shader_bit_encoding : enable
void main()
{
    return;
}
#endif
"
}
}
Program "fp" {
SubProgram "glcore " {
"// shader disassembly not supported on glcore"
}
}
 }
}
}
 生成的代码被分割为两个块,vp和fp,分别用于顶点程序和片段程序。然而,在OpenGL的情况下,两个程序都在vp块中。两个主要函数对应于两个我们的空方法。 所以让我们关注这两个主要函数并忽略其他代码。
1
2
3
4
5
6
7
8
9
10
11
12
#ifdef VERTEX
void main()
{
    return;
}
#endif
#ifdef FRAGMENT
void main()
{
    return;
}
#endif
 这里是为Direct3D 11生成的代码,让我们只剥离出那些有趣的部分。它看起来很不同,但很明显,代码没有做太多的工作。
1
2
3
4
5
6
7
8
9
10
11
12
Program "vp" {
SubProgram "d3d11 " {
      vs_4_0
   0: ret
}
}
Program "fp" {
SubProgram "d3d11 " {
      ps_4_0
   0: ret
}
}
当我们处理我们的程序时,我会经常显示OpenGL Core和D3D11平台的编译代码,所以就可以对具体内部发生了什么有一个比较明确的认识。



2.4 导入其他文件

要生成具有功能的着色器代码,你会需要很多模板代码。比如定义公共变量、函数和其他东西的代码。 如果这是一个C#程序的话,我们会将这些代码放在其他的类中。但是着色器没有类的概念。它们只是一个包含所有代码的大文件,没有类或命名空间提供的分组功能。

幸运的是,我们可以将代码拆分成多个文件。你可以使用#include指令将不同文件的内容加载到当前文件中。一个典型的文件包括UnityCG.cginc,所以让我们这样做一下看看效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CGPROGRAM
 
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
 
#include "UnityCG.cginc"
 
void MyVertexProgram () {
 
}
 
void MyFragmentProgram () {
 
}
 
ENDCG
 UnityCG.cginc是与Unity捆绑在一起的着色器导入文件之一。它包括一些其他必要的文件,并包含一些通用的功能。

Unity 渲染教程(二):着色器基础
导入文件的层次结构,从UnityCG开始。

UnityShaderVariables.cginc 定义了渲染所需要的一大堆着色器变量,例如变换、相机和光照数据。 这些都是在需要的情况下,在Unity编辑器里面进行设置的。

HLSLSupport.cginc 设置的是那些无论你的目标平台是什么,你都可以使用相同的代码的东西。所以你不需要担心使用特定于某个平台的数据类型等事情。

UnityInstancing.cginc 是专门用于实例化支持的,这是一种减少绘制调用的特定渲染技术。虽然它不直接导入文件,它取决于UnityShaderVariables的信息。

请注意,这些文件的内容被有效地复制到你自己的文件中,替换了导入指令。这个过程发生在预处理步骤的期间,预处理步骤会执行所有的预处理指示。这些指令都是以哈希开头的语句,例如#include和#pragma。在预处理步骤完成之后,着色器代码被再次处理,并且被实际编译。




2.5  生成输出

为了渲染某些东西,我们的着色器程序必须能够输出结果。顶点程序必须返回顶点的最终坐标。 一共会有多少个坐标? 四个,因为我们使用的是4×4变换矩阵,正如这个系列的第1部分《矩阵》中所描述的那样。

将函数的类型从void更改为float4。float4只是四个浮点数的集合。但是现在,让我们只返回0。
1
2
3
float4 MyVertexProgram () {
    return 0;
}
我们现在得到的错误信息提示是关于缺少语义。着色器的编译器看到我们返回一个四个浮点数的集合,但是它不知道这个四个浮点数的集合代表着什么。所以它不知道图形处理器应该用它做什么。我们必须非常具体地了解我们的程序的输出。
在这种情况下,我们试图输出顶点的位置。 我们必须通过将SV_POSITION语义附加到我们的方法来指明这一点。 SV表示系统值,而POSITION表示最终顶点位置。
1
2
3
float4 MyVertexProgram () : SV_POSITION {
    return 0;
}
片段程序应该输出一个像素的RGBA颜色值。 我们可以使用float4 类型。返回0将为这个像素使用一个固定的颜色。
1
2
3
float4 MyFragmentProgram () {
    return 0;
}
片段程序也需要语义。 在这种情况下,我们必须指出最终的颜色应该写在哪里。 我们使用SV_TARGET,它是默认的着色器目标,也就是帧缓冲区,其中包含着我们正在生成的图像。
1
2
3
float4 MyFragmentProgram () : SV_TARGET {
    return 0;
}
 但是,等等,顶点程序的输出会被用作片段程序的输入。这表明片段程序应该得到一个与顶点程序的输出相匹配的参数。
1
2
3
float4 MyFragmentProgram (float4 position) : SV_TARGET {
    return 0;
}
 不管我们给参数起什么名字,我们都必须确保使用正确的语义。
1
2
3
4
5
float4 MyFragmentProgram (
    float4 position : SV_POSITION
) : SV_TARGET {
    return 0;
}
我们的着色器再次编译,没有错误信息提示,但是球体消失了。这不应该令人感到惊讶的,因为我们将球体所有的顶点折叠到了一个点。

如果你看下编译后的OpenGL核心程序,你会看到他们现在在写入输出值。我们的单颜色值确实已被四分量向量所代替。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifdef VERTEX
void main()
{
    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif
#ifdef FRAGMENT
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif
 D3D11程序也是如此,尽管语法是不同的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Program "vp" {
SubProgram "d3d11 " {
      vs_4_0
      dcl_output_siv o0.xyzw, position
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret
}
}
Program "fp" {
SubProgram "d3d11 " {
      ps_4_0
      dcl_output o0.xyzw
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret
}
}



2.6  对顶点进行变换

为了能够让我们的球再次显示出来,我们的顶点程序必须产生一个正确的顶点位置。 为此,我们需要知道顶点在物体空间中的位置。我们可以通过向我们的函数添加一个带POSITION语义的变量来访问它。然后将该位置提供为齐次坐标的形式  ,所以它的类型是float4。
1
2
3
float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
    return 0;
}
 让我们从直接返回这个位置信息开始。
1
2
3
float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
    return position;
}
 编译后的顶点程序现在有一个顶点的输入信息并将其复制到其输出里面去。
1
2
3
4
5
6
in  vec4 in_POSITION0;
void main()
{
    gl_Position = in_POSITION0;
    return;
}
1
2
3
4
5
6
Bind "vertex" Vertex
      vs_4_0
      dcl_input v0.xyzw
      dcl_output_siv o0.xyzw, position
   0: mov o0.xyzw, v0.xyzw
   1: ret

Unity 渲染教程(二):着色器基础
原始顶点位置。

黑色球体将变得可见,但它的位置被扭曲。这是因为我们使用的是物体空间的位置,我们把球在物体空间的位置当做了球的显示位置。因此,移动球体将在视觉上不会产生差别。

我们必须将原始的顶点位置乘以模型-视图-投影矩阵。模型-视图-投影矩阵将对象的变换层次与相机变换和投影相结合,就像我们在这个系列的第1部分《矩阵》中做的那样。

4 x 4的模型-视图-投影矩阵在UnityShaderVariables中被定义为UNITY_MATRIX_MVP。我们可以使用mul函数将它与顶点的位置相乘。这将把我们的球体正确地投影到显示器上去。你还可以移动、旋转和缩放它,并且图像将按照预期改变。
1
2
3
float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
    return mul(UNITY_MATRIX_MVP, position);
}

Unity 渲染教程(二):着色器基础
位置投影正确的球体。

 如果你检查OpenGLCore平台上编译出来的顶点程序,你会注意到一个统一的变量突然出现在代码里面。即使它们没有被代码使用并且将被忽略,访问矩阵这个事情触发了编译器将整个块都导入进来了。

 你还将看到矩阵乘法被编码为一堆乘法和加法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
uniform     vec4 _Time;
uniform     vec4 _SinTime;
uniform     vec4 _CosTime;
uniform     vec4 unity_DeltaTime;
uniform     vec3 _WorldSpaceCameraPos;
in  vec4 in_POSITION0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    return;
}
 D3D11的编译器不会包含未使用的变量。 它用一个mul和三个mad指令对矩阵乘法进行编码。mad指令表示加法之后紧跟着乘法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Bind "vertex" Vertex
ConstBuffer "UnityPerDraw" 352
Matrix 0 [glstate_matrix_mvp]
BindCB  "UnityPerDraw" 0
      vs_4_0
      dcl_constantbuffer cb0[4], immediateIndexed
      dcl_input v0.xyzw
      dcl_output_siv o0.xyzw, position
      dcl_temps 1
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw
   3: mad o0.xyzw, cb0[3].xyzw, v0.wwww, r0.xyzw
   4: ret



3.  给像素颜色

现在我们得到了正确的形状,让我们来添加一些颜色。最简单的是使用固定的颜色,例如黄色。
1
2
3
4
5
float4 MyFragmentProgram (
    float4 position : SV_POSITION
) : SV_TARGET {
    return float4(1, 1, 0, 1);
}

Unity 渲染教程(二):着色器基础
黄颜色的球体。

 当然,你不总是想要黄色的对象。在理想情况下,我们的着色器将支持任何的颜色。然后,你可以使用该材质配置你要应用的颜色。这是通过着色器的属性完成的。




3.1 着色器的属性

着色器属性在单独的块中声明。让我们将它添加到着色器代码的顶部。
1
2
3
4
5
6
7
8
9
Shader "Custom/My First Shader" {
 
    Properties {
    }
 
    SubShader {
        
    }
}
 在新的块中放入一个名为_Tint的属性。你可以给它任何名称,但通常的约定是以下划线开始,后面跟一个大写字母,然后是小写字母。这么做是确保没有什么别的地方会使用这个名字,以防止意外的重复名称。
1
2
3
Properties {
    _Tint
}
 属性的名称后面必须跟着一个字符串和一个类型,放在圆括号中,就像调用一个方法一样。该字符串用于标记材质检查器中的属性。 在这种情况下,类型是isColor。
1
2
3
Properties {
    _Tint ("Tint", Color)
}
 属性声明的最后一部分是给一个默认值赋值。让我们将这个默认值设置为白色。
1
2
3
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
}
 我们的tint属性不应该出现在我们着色器检查器的属性部分。

Unity 渲染教程(二):着色器基础
着色器属性。

 当你选择材质的时候,你将看到新的Tint属性,被设置为白色。你可以将Tint属性更改为任何你喜欢的颜色,比如说是绿色。

Unity 渲染教程(二):着色器基础
 材质的属性。




3.2 访问属性

 要实际使用属性,我们向着色器代码添加了一个变量。它的名称必须完全匹配属性名称,因此它的名称将是_Tint。然后我们可以在我们的片段程序中简单地返回这个变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
#include "UnityCG.cginc"
 
float4 _Tint;
 
float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
    return mul(UNITY_MATRIX_MVP, position);
}
 
float4 MyFragmentProgram (
    float4 position : SV_POSITION
) : SV_TARGET {
    return _Tint;
}
请注意,变量必须在使用之前进行定义。虽然你可以改变C#类中的字段和方法的顺序,这在C#中没有问题,但是对于着色器不是这样的。着色器的编译器是从上到下工作的。它不会往后看一下。

编译号的片段程序现在包括tint变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
uniform     vec4 _Time;
uniform     vec4 _SinTime;
uniform     vec4 _CosTime;
uniform     vec4 unity_DeltaTime;
uniform     vec3 _WorldSpaceCameraPos;
uniform     vec4 _Tint;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = _Tint;
    return;
}
1
2
3
4
5
6
7
8
ConstBuffer "$Globals" 112
Vector 96 [_Tint]
BindCB  "$Globals" 0
      ps_4_0
      dcl_constantbuffer cb0[7], immediateIndexed
      dcl_output o0.xyzw
   0: mov o0.xyzw, cb0[6].xyzw
   1: ret

Unity 渲染教程(二):着色器基础
颜色为绿色的球。




3.3 从顶点程序传递到片段程序

到目前为止,我们给了所有的像素相同的颜色,但是这是相当受限的情况。通常情况下,顶点数据起着很大的作用。举个例子来说,我们可以将位置解释为颜色。然而,变换后的位置不是非常有用的。因此,让我们使用网格中的局部位置信息作为颜色。我们该如何将额外的数据从顶点程序传递给片段程序?

GPU通过光栅化三角形来创建图像。它需要三个经过处理的顶点并在它们之间进行插值。对于由三角形覆盖的每个像素,它会调用片段程序,并传递内插值后的数据。

Unity 渲染教程(二):着色器基础
对顶点数据进行插值。

因此,顶点程序的输出并不是直接用作片段程序的输入。插值过程位于两者之间。 在这个图里面SV_POSITION数据被进行内插值,但是其他数据也可以进行内插值。

要访问插值后的局部位置信息,请向片段程序中添加参数。 因为我们只需要X、Y和Z组件,我们用float3就足够了。然后我们可以输出位置信息,就像它是一种颜色一样。我们必须提供第四个颜色分量,可以简单的只保留为1。
1
2
3
4
5
6
float4 MyFragmentProgram (
    float4 position : SV_POSITION,
    float3 localPosition
) : SV_TARGET {
    return float4(localPosition, 1);
}
 再次提醒下,我们必须使用语义来告诉编译器该如何解释这些数据。这一次我们将使用TEXCOORD0。
1
2
3
4
5
6
float4 MyFragmentProgram (
    float4 position : SV_POSITION,
    float3 localPosition : TEXCOORD0
) : SV_TARGET {
    return float4(localPosition, 1);
}
 编译好的片段着色器现在将使用内插值后的数据而不是使用统一的颜色。
1
2
3
4
5
6
7
8
in  vec3 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0.xyz = vs_TEXCOORD0.xyz;
    SV_TARGET0.w = 1.0;
    return;
}
1
2
3
4
5
6
  ps_4_0
   dcl_input_ps linear v0.xyz
   dcl_output o0.xyzw
0: mov o0.xyz, v0.xyzx
1: mov o0.w, l(1.000000)
2: ret
 当然顶点程序必须输出局部位置信息才能正常工作。我们可以通过向它添加一个输出参数,使用相同的TEXCOORD0语义来做到这一点。 顶点和片段函数的参数名称不需要匹配。这是通过语义进行匹配的。
1
2
3
4
5
6
float4 MyVertexProgram (
    float4 position : POSITION,
    out float3 localPosition : TEXCOORD0
) : SV_POSITION {
    return mul(UNITY_MATRIX_MVP, position);
}
 要通过顶点程序传递数据,从位置数据localPosition里面复制X、Y和Z分量。
1
2
3
4
5
6
7
float4 MyVertexProgram (
    float4 position : POSITION,
    out float3 localPosition : TEXCOORD0
) : SV_POSITION {
    localPosition = position.xyz;
    return mul(UNITY_MATRIX_MVP, position);
}
 额外的顶点程序输出包含在编译器着色器中,这样我们将看到我们的球体被正确的渲染。
1
2
3
4
5
6
7
8
9
10
11
12
in  vec4 in_POSITION0;
out vec3 vs_TEXCOORD0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    vs_TEXCOORD0.xyz = in_POSITION0.xyz;
    return;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Bind "vertex" Vertex
ConstBuffer "UnityPerDraw" 352
Matrix 0 [glstate_matrix_mvp]
BindCB  "UnityPerDraw" 0
      vs_4_0
      dcl_constantbuffer cb0[4], immediateIndexed
      dcl_input v0.xyzw
      dcl_output_siv o0.xyzw, position
      dcl_output o1.xyz
      dcl_temps 1
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw
   3: mad o0.xyzw, cb0[3].xyzw, v0.wwww, r0.xyzw
   4: mov o1.xyz, v0.xyzx
   5: ret

Unity 渲染教程(二):着色器基础
将局部位置信息解释为颜色。




3.4 使用结构

你是否认为我们的程序的参数列表看起来很乱?其实它只会变得更糟,因为我们要在顶点程序和片段程序之间传递越来越多的数据。因为顶点程序的输出应该匹配片段程序的输入,所以如果我们可以在一个地方定义参数列表将是非常方便的。 幸运的是,我们可以这样做。

我们可以定义数据结构,它们只是变量的集合。除了语法有点不同以外,它们类似于C#中的结构。这里是一个结构体,定义了我们正在内插值的数据。请注意要在定义后使用分号。
1
2
3
4
struct Interpolators {
    float4 position : SV_POSITION;
    float3 localPosition : TEXCOORD0;
};
 使用这个结构来让我们的代码更整洁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float4 _Tint;
 
struct Interpolators {
    float4 position : SV_POSITION;
    float3 localPosition : TEXCOORD0;
};
 
Interpolators MyVertexProgram (float4 position : POSITION) {
    Interpolators i;
    i.localPosition = position.xyz;
    i.position = mul(UNITY_MATRIX_MVP, position);
    return i;
}
 
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return float4(i.localPosition, 1);
}




3.5 调整颜色

因为颜色值为负的情况被修正为零,我们的球体在渲染的时候看起来相当的暗。 因为默认球体在物体空间中的半径为1/2,所以颜色通道的值最终位于-1/2和1/2之间。 我们想将它们移动到0-1范围,我们可以通过给所有通道的值加上1/2来改变这一点。
1
return float4(i.localPosition + 0.5, 1);

Unity 渲染教程(二):着色器基础
 被重新着色的局部位置信息。


1
return float4(i.localPosition + 0.5, 1) * _Tint;
1
2
3
4
5
6
7
8
9
10
11
uniform     vec4 _Tint;
in  vec3 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
vec4 t0;
void main()
{
    t0.xyz = vs_TEXCOORD0.xyz + vec3(0.5, 0.5, 0.5);
    t0.w = 1.0;
    SV_TARGET0 = t0 * _Tint;
    return;
}
1
2
3
4
5
6
7
8
9
10
11
12
ConstBuffer "$Globals" 128
Vector 96 [_Tint]
BindCB  "$Globals" 0
      ps_4_0
      dcl_constantbuffer cb0[7], immediateIndexed
      dcl_input_ps linear v0.xyz
      dcl_output o0.xyzw
      dcl_temps 1
   0: add r0.xyz, v0.xyzx, l(0.500000, 0.500000, 0.500000, 0.000000)
   1: mov r0.w, l(1.000000)
   2: mul o0.xyzw, r0.xyzw, cb0[6].xyzw
   3: ret

Unity 渲染教程(二):着色器基础
局部位置信息有红色色调,所以只有X变量剩下了。




4. 纹理

如果要向网格添加更多明显的细节和多样性,而不想添加更多的三角形的话,那么就可以使用纹理。然后将图像投影到网格三角形上。

纹理坐标用于控制投影。纹理坐标取值范围在一个标准单位大小的二维坐标对,覆盖了整个图像,而与纹理的实际长宽比无关。水平坐标被称为U,垂直坐标被称为V。因此,它们通常被称为UV坐标。

Unity 渲染教程(二):着色器基础
覆盖整个图像的UV坐标。

 U坐标的值是从左到右增长的。因此,在图像的左侧为0,在图像的中间为1/2,在图像的右侧为1。V坐标的工作方式与U坐标相同,只是在垂直方向上作用。它是从底部到顶部增长,除了Direct3D是个例外,它从顶部到底部增长。你几乎不需要担心这种差异。




4.1 使用UV坐标

Unity的默认网格具有适合纹理映射的UV坐标。 顶点程序可以通过具有TEXCOORD0语义的参数来访问它们。
1
2
3
4
5
6
7
8
9
Interpolators MyVertexProgram (
                float4 position : POSITION,
                float2 uv : TEXCOORD0
            ) {
                Interpolators i;
                i.localPosition = position.xyz;
                i.position = mul(UNITY_MATRIX_MVP, position);
                return i;
            }
 我们的顶点程序现在使用多个输入参数。再次说明下,我们可以使用一个结构来对它们进行分组。
1
2
3
4
5
6
7
8
9
10
11
struct VertexData {
    float4 position : POSITION;
    float2 uv : TEXCOORD0;
};
 
Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.localPosition = v.position.xyz;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    return i;
}
 让我们直接将UV坐标传递给片段程序,来替换局部位置信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
            struct Interpolators {
                float4 position : SV_POSITION;
                float2 uv : TEXCOORD0;
//                float3 localPosition : TEXCOORD0;
            };
 
            Interpolators MyVertexProgram (VertedData v) {
                Interpolators i;
//                i.localPosition = v.position.xyz;
                i.position = mul(UNITY_MATRIX_MVP, v.position);
                i.uv = v.uv;
                return i;
            }
 我们可以通过将它们解释为颜色通道来使得UV坐标可见,就像对局部位置信息一样的处理一样。 举个简单的例子来说,让我们把U变为红色,让我们把V变为绿色,而蓝色始终为1。
1
2
3
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return float4(i.uv, 1, 1);
}
 你会看到编译后的顶点程序,现在将UV坐标从顶点数据复制到插值器的输出里面。
1
2
3
4
5
6
7
8
9
10
11
12
13
in  vec4 in_POSITION0;
in  vec2 in_TEXCOORD0;
out vec2 vs_TEXCOORD0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    vs_TEXCOORD0.xy = in_TEXCOORD0.xy;
    return;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Bind "vertex" Vertex
Bind "texcoord" TexCoord0
ConstBuffer "UnityPerDraw" 352
Matrix 0 [glstate_matrix_mvp]
BindCB  "UnityPerDraw" 0
      vs_4_0
      dcl_constantbuffer cb0[4], immediateIndexed
      dcl_input v0.xyzw
      dcl_input v1.xy
      dcl_output_siv o0.xyzw, position
      dcl_output o1.xy
      dcl_temps 1
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw
   3: mad o0.xyzw, cb0[3].xyzw, v0.wwww, r0.xyzw
   4: mov o1.xy, v1.xyxx
   5: ret
 Unity围绕它的球体来生成UV坐标,这样的话会在图像的两级也就是顶部和底部进行折叠。你会看到一个从北极到南极的缝,图像的左侧和右侧在这条缝这里连接起来。所以沿着那个接缝,你的U坐标值都是0和1。这是通过沿着接缝有重复的顶点实现的,除非它们的U坐标是相同的。

Unity 渲染教程(二):着色器基础Unity 渲染教程(二):着色器基础
UV作为颜色,从正面和上方看到的效果。




4.2 添加一张纹理

要添加纹理的话,你需要导入图像文件。这里是我将用于测试目的纹理。

Unity 渲染教程(二):着色器基础
用于测试目的纹理。

 你可以通过将图像拖动到项目视图中来将图像添加到项目中。你也可以通过Asset / Import New Asset...菜单项来将图像添加到项目中。在默认设置下,图像将作为二维纹理导入,这很好。

Unity 渲染教程(二):着色器基础Unity 渲染教程(二):着色器基础
使用默认设置导入纹理。

要使用纹理,我们必须添加另一个着色器属性。常规纹理属性的类型是二维,因为还有其他类型的纹理。默认值是一个字符串,它会引用一张Unity的默认纹理(白色,黑色或灰色)。

常见的一个约定是命名主要纹理为_MainTex,所以我们将使用这个名字。 这也使你在需要的时候能够使用方便的Material.mainTexture属性通过脚本来访问它。
1
2
3
4
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
    _MainTex ("Texture", 2D) = "white" {}
}
 现在我们可以通过拖动或通过”选择”按钮将纹理分配给我们的材质。

Unity 渲染教程(二):着色器基础
分配给我们的材质的纹理。

 我们可以通过使用类型为sampler2D的变量来访问着色器中的纹理。
1
2
float4 _Tint;
sampler2D _MainTex;
 使用tex2D函数在片段程序中对具有UV坐标的纹理进行采样。
1
2
3
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return tex2D(_MainTex, i.uv);
}
1
2
3
4
5
6
7
8
uniform  sampler2D _MainTex;
in  vec2 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = texture(_MainTex, vs_TEXCOORD0.xy);
    return;
}
1
2
3
4
5
6
7
8
SetTexture 0 [_MainTex] 2D 0
     ps_4_0
     dcl_sampler s0, mode_default
     dcl_resource_texture2d (float,float,float,float) t0
     dcl_input_ps linear v0.xy
     dcl_output o0.xyzw
  0: sample o0.xyzw, v0.xyxx, t0.xyzw, s0
  1: ret

Unity 渲染教程(二):着色器基础Unity 渲染教程(二):着色器基础
加上纹理以后的球体。

现在,每个片段程序都要采样纹理,它将显示投影在球体上。纹理被包裹在球体上,如预期那样,但它会在极点附近出现非常大的摆动和不稳定。为什么会这样呢?

发生纹理失真是因为在三角形之间的插值是线性的。Unity的球体在极点附近只有几个三角形,其中UV坐标的扭曲最大。 因此,UV坐标在从顶点到顶点的变化是非线性的,但在顶点之间,它们的变化是线性的。这样做的结果就是,纹理中的直线在三角形边界处突然改变方向。

Unity 渲染教程(二):着色器基础
三角形的线性插值。

 不同的网格具有不同的UV坐标,这会产生不同的映射。Unity的默认球体使用经度-纬度的纹理映射,而网格是低分辨率的立方体球体。低分辨率的立方体球体足以进行测试,但你最好使用自定义球面网格,这样能够得到更好的结果。


 不同的纹理预览形状。

 最后,我们可以考虑使用色调来调整球体的纹理外观。
1
return tex2D(_MainTex, i.uv) * _Tint;

Unity 渲染教程(二):着色器基础
 带有黄色色调的纹理。



4.3 平铺和偏移

在我们为我们的着色器添加了纹理属性之后,材质检查器不仅添加了纹理字段。 它还添加了平铺和偏移控件。然而,当前改变这些二维向量并没有什么效果。

这个额外的纹理数据存储在材质中,也可以由着色器访问。你通过使用与关联材料相同名称的变量,加上_ST后缀。此变量的类型必须为float4。
1
2
sampler2D _MainTex;
float4 _MainTex_ST;
 平铺向量用于缩放纹理,因此在默认情况下为(1,1)。它存储在变量的XY部分。要使用它,只需将它与UV坐标相乘就可以了。这可以在顶点着色器或片段着色器中完成。在顶点着色器中执行它是有意义的,所以我们只对每个顶点程序而不是每个片段程序执行这段乘法。
1
2
3
4
5
6
Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.uv = v.uv * _MainTex_ST.xy;
    return i;
}


Tiling.

 偏移部分会移动纹理并存储在变量的ZW分量之中。它是在缩放后添加到UV坐标里面的。
1
i.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;


Offset.

 UnityCG.cginc里面包含了一个非常方便的宏,为我们简化了这个模板。我们可以使用它作为一个方便的缩写。
1
i.uv = TRANSFORM_TEX(v.uv, _MainTex);




5. 纹理设置

 到目前为止,我们已经使用了默认的纹理导入设置。让我们来看看几个选项,看看他们都是做什么的。

Unity 渲染教程(二):着色器基础
 默认的纹理导入设置。

环绕模式指示的是对位于0-1这个取值范围之外的UV坐标进行采样的时候发生的情况。当环绕模式设置为约束的时候,UV被约束在0-1的范围内。这意味着超出边缘的像素与位于边缘的像素相同。当环绕模式设置为重复的时候,UV出现环绕。这意味着超出边缘的像素与纹理的相对侧上的像素相同。 默认模式是重复纹理,这会导致纹理出现平铺。

如果你不想要平铺纹理的话,你会想要约束UV坐标的值。这有利于防止纹理重复,只是复制纹理的边界,这会使纹理看起来像是被拉伸的。

Unity 渲染教程(二):着色器基础
在(2,2)的平面上进行平铺。

 当保持在0-1范围内时,环绕模式有效吗?


Unity 渲染教程(二):着色器基础
Tiling on the edge.



5.1  Mipmap和过滤

当纹理的像素(texels)与投影到的像素不完全匹配的时候,会发生什么?这会造成一定的不匹配,这必须以某种方式解决。 这就是通过过滤模式索要控制的内容。

最直接的过滤模式是点模式(无滤波器)。这意味着在对纹理进行采样的时候,某些UV坐标会使用离它最近的纹理。这将给纹理块状的外观,除非纹理的像素恰好映射到显示像素。 因此,它通常用于像素的完美渲染,或者当需要块状样式的时候。

默认是使用双线性滤波。当纹理在两个纹理像素之间的某处被采样的时候,这两个纹理像素会被进行内插值。由于纹理是二维的,这会沿着U和V轴发生内插值。 因此,双线性滤波不只是线性滤波。

当纹理像素的密度小于显示像素的密度时,这个方法会起作用,因此当你放大纹理的时候,结果会看起来模糊。在相反的情况下这个方法不会起作用,比如说当你缩小的纹理的时候。 相邻的显示像素中的空间将多于一个纹理像素。这意味着纹理的一部分将被跳过,这将导致效果不好的转换,就好像图像被锐化一样。

这个问题的解决方案是每当纹理像素的密度变得太高的时候会使用较小的纹理。 显示屏上显示的纹理越小,那么应该使用的版本就越小。这些较小的版本称为mipmaps,并且会自动为你生成。每个连续的mipmap具有上一级mipmap的宽度和高度的一半。因此,当原始的纹理大小为512x512的时候,mip映射依次是256x256,128x128,64x64,32x32,16x16,8x8,4x4和2x2。


  Mipmap等级。

 如果你喜欢的话,你可以禁用mipmap。首先,将纹理类型设置为高级。然后你可以禁用Mipmap并进行更改。 看到差异的一个好方法是使用像四边形的平面对象,并从一个固定的角度去看它。

Unity 渲染教程(二):着色器基础
Unity 渲染教程(二):着色器基础
在有mipmap和没有mipmap时候的情况。

 那么在哪里使用哪个mipmap级别,它们看起来有多不同?通过在高级纹理设置中启用Fadeout Mip贴图,我们可以使过渡可见。在启用Fadeout Mip贴图的时候,渐变范围滑块将显示在检查器中。它定义了一个mipmap范围,mipmap映射范围将过渡到纯灰色。通过使这个过渡单步展现,你会得到一个尖锐的过渡一直到灰色。进一步将单步范围向右移动,后面的转换将会发生。

Unity 渲染教程(二):着色器基础
对mipmap的高级设置。

褪色到灰色的用途是什么?

要获得此效果的良好视图,现在将纹理的Aniso等级l设置为0。

Unity 渲染教程(二):着色器基础
Unity 渲染教程(二):着色器基础
Unity 渲染教程(二):着色器基础
连续的mipmap级别。

一旦你知道了各种mipmap的级别,你应该能够看到他们之间纹理质量的突然变化。 随着纹理投影的变小,纹理像素的密度会增加,这使得它看起来更清晰。 直到突然下一个mipmap等级切换,它就又变得模糊了。

所以在没有mipmap的情况下,你看到的视觉效果是从模糊到锐利,再到太尖锐。而在有mipmap的情况下,你看到的视觉效果是从模糊到尖锐,再到突然再次模糊,然后尖锐,再次突然模糊,等等。

那些模糊锐利是双线性滤波的特征。你可以通过将过滤器模式切换到三线性滤波来消除那些模糊锐利。这与双线性过滤的原理相同,但它也在相邻的mipmap级别之间内插。因此得名三线性滤波。这使得采样更昂贵,但它平滑了mipmap级别之间的过渡。

Unity 渲染教程(二):着色器基础
正常和灰色mipmap之间的三线性滤波。

 另一个有用的技术是各向异性过滤。你可能已经注意到,当你设置为0的时候,纹理变得模糊。这与mipmap级别的选择有关。

 当纹理以一个角度进行投射时,由于透视,你最终得到的结果是它的一个维度变形比另一个维度变形更大。一个很好的例子是纹理的地平面。在一定距离处,纹理的前-后尺寸将显得比左-右尺寸小得多。

而选择哪个mipmap级别是基于最差维度进行选择的。如果差异很大的话,那么你将得到在一个维度上非常模糊的结果。各向异性过滤通过对尺寸的去耦合来减轻这种影响。除了均匀地缩小纹理外,它还提供在任一维度上缩放不同比例的版本。 所以你不只是有一个大小为256x256的mipmap,也有大小为256x128、256x64的mipmap等等。

Unity 渲染教程(二):着色器基础
Unity 渲染教程(二):着色器基础
不使用各向异性过滤和使用各向异性过滤的对比。

 注意,那些额外的mipmap不像常规的mipmap那样是预先生成的。相反,它们通过执行额外的纹理采样来进行模拟。所以他们不需要更多的空间,但是采样变得更昂贵了。

Unity 渲染教程(二):着色器基础
各向异性双线性滤波,逐步过滤为灰色。

 各向异性过滤的深度是由Aniso的等级进行的控制。在Aniso的等级为0的时候,代表着禁用各向异性过滤。 在Aniso的等级为1的时候,启用各向异性过滤并提供最小的效果。在Aniso的等级为16的时候,各向异性过滤处于其最大值。但是,这些设置受项目质量设置的影响。

你可以通过Edit / Project Settings / Quality来访问质量设置。你将在“渲染”部分中找到各向异性纹理的设置。

Unity 渲染教程(二):着色器基础
渲染质量设置。

 当禁用各向异性纹理的时候,就不会发生各向异性过滤,无论纹理的设置如何。 当它设置为”Per Texture”的时候,各向异性过滤是否起起用完全由每个单独的纹理控制。它也可以设置为强制开启,这将使得每个纹理的Aniso 等级至少设置为9。但是,Aniso 等级设置为0的纹理仍然不会使用各向异性过滤。

这个系列的下一篇教程是《合并纹理》。