Catlike渲染教程之Shader基础

来源:互联网 发布:c语言求平方根函数 编辑:程序博客网 时间:2024/06/05 08:51

渲染二 Shader基础

  • 变换顶点
  • 给像素着色
  • 使用shader属性
  • 在顶点程序和片段程序间传递数据
  • 检查编译的着色器代码
  • 对贴图进行采样,并使用平铺和缩放

这是渲染系列教程的第2篇。第1部分是关于矩阵。是时候写我们的第1个shader并引入一张贴图了。
这个教程使用的版本是Unity5.4.0。




贴上贴图的球体

默认的场景

当我们在Unity中创建一个新的场景,开始给你的是一个默认的相机和方向光。通过GameObject / 3D Object / Sphere创建一个简单的球体,把它放置在原点,相机就放在它前面。




贴上贴图的球体

这是一个非常简单的场景,但是已经有了很多复杂的渲染在里面了。摆脱所有的花哨的东西而只关注基础的,将更好地帮助我们掌握渲染过程。

去除已有的复杂渲染

通过Window / Lighting菜单,我们可以查看一下光照贴图设置。这会弹出一个带三个标签页的光照设置的弹窗。目前我们只关心这个Scene标签页里面的东西。




默认的光照设置

这部分是关于环境光,在这里我们可以选择设置天空盒。目前天空盒用于场景的背景、环境光和反射。因此,我们把这个天空盒关掉。
你也可以关掉预计算和实时全局光照面板。我们将很快不会用到这些选项了。




没有天空盒的情况

没有了天空盒,环境光的光源默认切换到了一个固定的颜色。这个默认的固定颜色是深灰色,略带一点蓝色。这个颜色是从哪来的呢?
背景色被定义在每一个相机属性里面。默认
每个相机都定义了一个背景颜色。相机默认渲染的是天空盒,如果天空盒丢失了或者说没有设置,那么就反过来使用这个我们设置在相机上的背景色。

为什么背景颜色的alpha用5来代替255?

不知道为什么默认alpha的值是5。但是没有关系,这个颜色会完全替换掉前一张图像的,它们不是混合的。为了进一步简化渲染,请禁用方向光,或删除它。 这将摆脱场景中的直接照明以及它所投射的阴影。 剩下的就是纯色的背景,带一个环境光颜色的球体的轮廓。

为了进一步简化渲染,请禁用方向光,或删除它。 这将摆脱场景中的直接照明以及它所投射的阴影。 剩下的就是纯色的背景,带一个环境光颜色的球体的轮廓。




没有光照的情况

从对象到图像

我们非常简单的场景分为两个步骤。 首先,图像用相机的背景颜色填充。 然后我们的球体的轮廓被绘制在上面。

Unity如何知道它必须画一个球体? 我们有一个球体对象,该对象具有网格渲染器组件。 如果这个对象位于相机视图的内部,则应该呈现。 Unity通过检查对象的边界框是否与相机的视图平截头体相交来验证。

什么是bounding box?

找到一个mesh刚好能够匹配的的最小的盒子,对于任何mesh,这个盒子就是它的bounding box。这个盒子是从物体的mesh自动导出的。你可以认为bounding boxes简单近似一个面片所占的体积。如果你不能看到这个box,你也看不到这个mesh。




默认的球体对象

transform组件用于改变位置、旋转、缩放。实际上,如第1部分“矩阵”所述,使用整个变换层次结构。 如果对象在相机的视图中最终出现,则计划进行渲染。
最后,GPU的任务是渲染对象的网格。 特定的渲染指令由对象的材质定义。 材质球引用着色器(一个GPU程序 ,任何设置都可能有)。




谁控制什么

我们的物体目前还用的是默认的材质球,这个材质球使用的是Unity的标准shader。我们将从头开始创建一个我们的shader来代替这个默认的shader。

你的第一个Shader

创建一个新的shader,通过Assets / Create / Shader / Unlit Shader,把它命名为My First Shader




你的第一个shader

打开这个文件并删除全部内容,我们从头开始写。
一个shader用Shader关键字来定义。紧跟着的是一个字符串,这个字符串描述的是一个菜单项,这个菜单项你是可以用来选择这个shader的。这个字符串不需要匹配文件的名字

Shader "Custom/My First Shader" {}

保存文件,你会得到一个警告,警告说的是这个shader是不支持的,因为没有sub-shaders而且没有回滚(fallbacks)。那是因为现在是空的。

尽管目前这个shader并没有功能,我们也可以把它附给材质球。通过Assets / Create / Material创建一个材质球,并从shader的下拉菜单中选择我们的shader。






使用了这个shader的材质球

把这个球体用上我们的自己的材质球,这个球体会变成粉色。这是因为当我们的shader发生错误时,Unity就会给这个颜色来引起你的注意。
objectsphere
这个shader的错误提到了sub-shaders。你可以使用sub-shaders来把多个shader的变体组合在一起。这样就允许你提供不同的子shader来针对不同的编译平台或这语法细节。比如,你可以用一个sub-shader来针对PC平台,另外一个sub-shader来针对移动平台。

Shader "Custom/My First Shader" {    SubShader {    }}

sub-shader必须至少包含一个pass。shader的pass是物体实际渲染的地方。我们现在使用的是一个pass,但是pass可能会有多个。使用多个pass意味着物体将会被渲染多次。

Shader "Custom/My First Shader" {    SubShader {        Pass {        }    }}

我们的球体现在变成了白色,因为我们现在使用的是一个空的pass。如果是这样,那说明我们的shader目前没有错误了。




一个白色的球体

Shader程序

现在是编写我们自己的着色器程序的时候了。 我们使用Unity的着色语言来实现,这是HLSL和CG着色语言的一个变体。 我们必须使用CGPROGRAM关键字来表示我们的代码的开始。 我们必须使用ENDCG关键字终止。

Pass {    CGPROGRAM    ENDCG}

为什么需要这些关键字?

着色器传递可以包含除了着色器程序之外的其他语句。 所以程序必须以某种方式分开。 为什么不使用另一个块呢? 不知道。 你会遇到更多这样的怪物。 他们通常是旧设计决策,只有一次,但不再存在。 由于向后兼容性,我们仍然坚持使用它们

着色器编译器现在抱怨着我们的着色器没有顶点和片段程序。 着色器由两个程序组成。 顶点程序负责处理网格的顶点数据。 这包括从对象空间到显示空间的转换,就像我们在第1部分中所做的那样。 片段程序负责着色位于网格三角形内的各个像素。




顶点和片元程序

我们必须告诉编译器去使用哪个程序,是使用顶点程序还是片段程序,通过使用指令–pragma。

CGPROGRAM    #pragma vertex MyVertexProgram    #pragma fragment MyFragmentProgramENDCG

什么是pragma?

词语pragma来自希腊语,指的是一个行动,或者需要做的事情。 它用于许多编程语言中以发出特殊的编译器指令。

编译器再次抱怨,这次是因为找不到我们指定的程序。 那是因为我们还没有定义它们。

顶点和片段程序是作为方法编写的,很像C#,尽管它们通常被称为函数。 我们简单地创建两个空的void方法和适当的名称。

CGPROGRAM    #pragma vertex MyVertexProgram    #pragma fragment MyFragmentProgram    void MyVertexProgram () {    }    void MyFragmentProgram () {    }ENDCG

这个时候,着色器将编译,球体将消失。 或者你仍然会收到错误。 这取决于您的编辑器使用的渲染平台。 如果您使用Direct3D 9,您可能会收到错误。

Shader的编译

Unity的shader编译器根据不同的平台,把shader代码和变换信息引入到不同的程序里面去。不同的平台需要不同的情景。例如,Direct3D针对Windows,OpenGL针对Macs,OpenGL ES针对移动平台。我们这里要处理的不仅仅是一个平台,而是多个,也就是所谓的跨平台。

最终决定使用哪个编译器决定于你针对哪个平台。这些编译器并不是完全相同的,每一个目标平台都可能选择不一样的结果,也就是说针对一个平台,并不是固定一个编译器就能搞定的。举个例子,我们的一个空的shader程序在Direct3D 11和OpenGL平台上运行良好,但是在Direct3D 9上面就编译失败而运行不了。

引入其他文件

要想写出一个功能性的shader的话,需要很多样板代码。这些代码定义了通用的变量,函数和其他东西。这些代码是c#代码,我们需要将它放在另外的类中。但是shader是没有类的。他们仅仅是一个包含了所有代码的大文件,并没有通过类和命名空间来打包成组。

还好的是,我们可以把这些文件分割成多个文件。你可以使用#include指令来引入不同的内容到当前的文件。一个典型的引入的文件就是UnityCG.cginc。

  • UnityShaderVariables.cginc
    定义了一组shader变量,这些变量对于渲染必备,比如变换(transformation),相机,光照数据。
  • HLSLSupport.cginc
    用来使得shader代码跨平台,同一份代码在不同平台上都能跑,不用担心平台特有的数据类型。
  • UnityInstancing.cginc
    是一种特殊的GPU instancing支持,一种特殊的减少Draw Call的技术。它没有直接被引用进入,而是依赖于UnityShaderVariables.cginc

注意要用include指令来替换,这些文件的内容才会有效地被拷贝到我们自己的文件。这个发生在预处理阶段,预处理阶段会取出所有的预处理指令。这些指令都是一以个hash开头的语句,像#inclue#pragma。待到这个预处理阶段完成后,这些代码才再处理,并实际开始编译。

产生Output

要渲染某个东西,我们的shader程序必须产生某些结果。顶点程序必须返回顶点最终的坐标。有多少了坐标呢?4个,因为我们用了一个4乘4的矩阵。

把这个函数的返回类型从void改成float4。一个float4由4个浮点数分量组成。
我们现在姑且先返回0。

float4 MyVertexProgram () {    return 0;}

0是合法的返回类型么?

当使用一个这样一个简单的数值返回值,编译器会自动重复的补全float分量。你也可以显式地返回float(0,0,0,0)。

我们现在得到了一个丢失语义 (missing semantics)的错误。编译器看到我们返回的是一个float4,但是它并不知道这个数据代表的是什么。因此编译器并不知道GPU应该拿着这个数据去干什么。我们对我们程序中的output必须要有一个非常明确的指定。
既然我们要输出顶点的坐标,我们就需要通过附加语义SV_POSITION来指定到我们的方法中去。SV代表系统变量的意思,POSITION代表最终的顶点坐标。

float4 MyVertexProgram () : SV_POSITION {    return 0;}

片段程序的目标是输出让每一个像素输出一个RGBA颜色值。我们也可以使用float4来进行输出。返回0将输出一个纯的黑色。

float4 MyFragmentProgram () {    return 0;}

alpha为0会是完全透明么?

如果我们的shader实际忽略了alpha通道的话,这的确是会完全透明。但是我们写的是一个不透明的shader。如果我们写的是一个半透明的shader,这就是对的。

片段程序同样需要语义。我们应该指出最终的颜色应该写到哪里去。我们使用SV_TARGET,这是默认的shader目标。答案是我们应该写到帧缓存中,这个地方包含了我们正在生成的图像。

float4 MyFragmentProgram () : SV_TARGET {    return 0;}

等一下,顶点程序的输出又被作为片段着色器的输入。这暗示说,片段程序应该传入一个参数,而这个参数就是顶点程序的输出。

float4 MyFragmentProgram (float4 position) : SV_TARGET {    return 0;}

这个参数的名字叫什么没关系,只是它的语义需要指定正确。

float4 MyFragmentProgram ( float4 position : SV_POSITION ) : SV_TARGET {    return 0;}

我们能够忽略掉这个参数么?

如果我们没有使用到这个参数,那么忽略它是可以的。但是,当有多个参数调用的时候,编译器就不知道了。因此最好是用顶点程序的输出匹配片段程序。

我们的shader再次编译没有错误了,但是球体却消失了。因为我们把所有的顶点折叠到了一个顶点上,所以会消失。
如果我们看看OpenGLCore程序,我们就可以看到它们现在被写到了输出值。我们的单个数值的确已经被替换为了一个4维的向量。

#ifdef VERTEXvoid main(){    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);    return;}#endif#ifdef FRAGMENTlayout(location = 0) out vec4 SV_TARGET0;void main(){    SV_TARGET0 = vec4(0.0, 0.0, 0.0, 0.0);    return;}#endif

在D3D11平台上同样,只是语法不同罢了。

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

顶点的变换

要让我们的球体显示回来,必须让我们的顶点程序产出一个正确顶点坐标。为了做到这点,我们需要知道顶点的object-space空间坐标。我们可以访问到这个坐标,只要我们添加语义POSITION到我们的函数中去。这个position支持齐次坐标[x,y,z,1],因此它是一个float4。

float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {                return 0;}

现在让我们直接返回这个坐标

float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {    return position;}

现在编译好的顶点程序已经有了一个顶点输入并且拷贝它作为了顶点程序的输出。

in  vec4 in_POSITION0;void main(){    gl_Position = in_POSITION0;    return;}
Bind "vertex" Vertex      vs_4_0      dcl_input v0.xyzw      dcl_output_siv o0.xyzw, position  0: mov o0.xyzw, v0.xyzw  1: ret




原生的顶点坐标

一个黑色的球体将变得可见,但它被扭曲了。因为我们正在使用一个模型空间坐标(object-space)来显示它们的位置。因此旋转这个球体并没发现有什么不同的变化。
我们需要用原始的顶点坐标乘以模型-视图-投影矩阵。这个矩阵结合了物体的视图变换,相机变换和投影变换,就像我们的第一部分将的那样。

这个4x4的MVP矩阵被定义在UnityShaderVariables中,名字叫做UNITY_MATRIX_MVP。我们可以使用mul函数来将顶点坐标和MVP矩阵相乘。这样就能让我们的球体正确显示了。你可以移动旋转缩放球体,渲染出来的图像正是我们预期想看到的那样。
Unity5.6以后会自动将mul(UNITY_MATRIX_MVP,)调用替换成UnityObjectToClipPos(),意思也就是说这两者等价。

如果你查看OpenGLCore顶点程序,你会发现很多uniform的变量突然出现了。尽管它们没有被用到,也可以被忽略,访问这个矩阵还是会触发编译器去引入这群变量。

什么是uniform变量?

Uniform修饰的变量,意味着对于一个面片所有的顶点和片段,这个变量拥有相同的值,意思是不会出现顶点、片段之间的这个变量的值不一样的情况。你可以在你自己的shader程序中,显示的标记一个变量为uniform的,但它不是必须的。

你会看到矩阵乘法,编码成一对乘法和加法。

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指令代表一个乘法后加一个加法。

Bind "vertex" VertexConstBuffer "UnityPerDraw" 352Matrix 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

像素着色

现在我们得到了正确的形状,我们继续给这个球体添加些颜色。最简单的是使用一个纯色,比如黄色。

float4 MyFragmentProgram (                float4 position : SV_POSITION            ) : SV_TARGET {                return float4(1, 1, 0, 1);}




黄色球体

当然你肯定不想总是显示一个黄色物体。理想情况下我们的shader应该支持任何颜色。于是你可以使用材质球来配置哪个颜色你想要去应用在这个物体上。这个就需要通过shader的属性来完成了。

Shader属性

Shader的属性定义在一个单独的块。在shader的顶部。

Shader "Custom/My First Shader" {    Properties {    }    SubShader {        …    }}

添加一个名为_Tint的属性到属性模块。你可以任意指定名字,但是约定俗成的是下划线开头,然后大驼峰。这样设计是因为其他的都不用这种习惯,这样可以来避免重复的名字。

Properties {        _Tint    }

属性名后面紧跟一对花括号,花括号里面包含一个字符串和一个类型,就像调用一个方法那样。字符串是用来在材质面板上显示一个label用的。目前这种情况下,这个类型应该是Color。

Properties {        _Tint ("Tint", Color)}

上面这种情况是把一个属性声明为默认的值。现在让我们把它设置为白色。

    Properties {        _Tint ("Tint", Color) = (1, 1, 1, 1)    }

我们的Tint属性现在应该能显示在shader检视面板的属性区域了。




Shader上的属性

当你选择你的材质球,你就可以看到新的Tint属性,设置成白色。你可以设置成你喜欢的任何颜色,比如绿色。




材质球上的属性

访问Shader属性

要实际使用到属性,我们得添加一个变量到我们的shader代码中。它的名字必须和属性名一样,因此叫_Tint。我们可以在片段程序中简单的返回它。

#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#语言那样可以改变字段和方法之间的顺序,在shader程序中这是不行的。shader的编译器会从头到尾自上而下的进行编译工作。
编译好的片段程序现在已经引入了tint变量。

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;}
ConstBuffer "$Globals" 112Vector 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




绿色的球体

从顶点到片段

目前为止我们给每一个像素同一个颜色,这很受限制。通常,顶点扮演者一个很重要的角色。比如,我们可以用一个位置来描述一个颜色。然而,变换了的位置并没有起到很大用处。因此我们用局部坐标来替代面片中的颜色。我们如何将额外的数据从顶点程序传递到片段程序中去呢?

GPU通过光栅化三角形来创建图像。一个三角形三个顶点,然后在这三个顶点之间插值。对于三角形覆盖的每一个像素,通过调用片段程序来传递这些插值。




顶点数据的插值

因此其实顶点程序的输出并没有直接作为输入传入片段程序中。插值过程位于两者之间(顶点程序与片段程序)。下面我们用SV_POSITION来进行插值,当然也可以用其他的东西来进行插值。

为了访问被插值出来的局部坐标,我们添加一个参数到片段程序中。因为我们仅仅需要X,Y,Z三个分量,我们用float3就足够了。我们可以输出位置作为颜色。我们得提供第4个颜色分量,简单起见,我们就指定为1。

float4 MyFragmentProgram (                float4 position : SV_POSITION,                float3 localPosition            ) : SV_TARGET {                return float4(localPosition, 1);}

再一次我们需要使用语义来告诉编译器如何去解释这个数据。我们使用TEXTURE0

float4 MyFragmentProgram (                float4 position : SV_POSITION,                float3 localPosition : TEXCOORD0            ) : SV_TARGET {                return float4(localPosition, 1);}
我们并没有使用纹理坐标,那为什么用TEXCOORD0没有一个针对插值数据的通用语义。大家都用纹理坐标语义来对任何事情进行插值,而不用顶点坐标。因为兼容性的原因。也有一个特殊的color语义,但很少拿来用,因为有些平台不支持,不可用。

编译的片段着色器将会使用插值数据来代替uniform tint。

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

当然顶点程序为了这项工作必须输出这个局部坐标local position。要实现这一点,我们可以通过在这个顶点程序的参数里面添加一个带有相同语义的参数。顶点程序和片段程序的参数的名字并不需要取得一样。有关系的是它们的语义要一致。

float4 MyVertexProgram (                float4 position : POSITION,                out float3 localPosition : TEXCOORD0            ) : SV_POSITION {                return mul(UNITY_MATRIX_MVP, position);}

拷贝position的X,Y,Z分量到localPostion,从片段程序中传递出去。

float4 MyVertexProgram (                float4 position : POSITION,                out float3 localPosition : TEXCOORD0            ) : SV_POSITION {                localPosition = position.xyz;                return mul(UNITY_MATRIX_MVP, position);}
.xyz是干什么用的?这个就是swizzle操作。它跟访问一个向量的单个分量很想,但是更灵活。你可以使用swizzle操作去过滤,重新排序,重复浮点数分量。例如,.x,.xy,.yx,.xx。当前情景下,我们使用它去获取前三个分量,忽略第4个分量。四个全的分量应该是.xyzw。你也可以使用颜色的习惯方式,即.rgba。

现在我们的shader得到了额外的顶点程序输出,我们可以看到球体被着上了颜色。

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;}
Bind "vertex" VertexConstBuffer "UnityPerDraw" 352Matrix 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




局部坐标当做颜色来进行插值

使用结构体

你觉得我们的参数列表看起来是不是很散乱?当我们传递的参数越来越多,这种情况会变得越来越糟糕。因为顶点程序的输出需要匹配片段程序的输入,如果我们把这些参数定义在一个地方,也就是做一次封装,就会变得方便多了。幸运的是,我们可以这样搞。
我们可以定义一个数据结构,其实它就是一个变量的集合。它类似于c#里面的结构体,除了语法上有一点点不一样。下面就是我们定义成的结构体,用来进行插值的。注意声明后面的分号的用法。

struct Interpolators {                float4 position : SV_POSITION;                float3 localPosition : TEXCOORD0;};

使用结构体让我们的代码变得整洁多了。

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

调整颜色

因为负数会被clamp到0,,所以我们的球体显得相当的黑。因为默认球体的物体空间(object-space)1/2半径,所以颜色通道最终在-1/2到1/2,我们想要把这些值移到0到1之间。我们可以给所有的值加上一个1/2。

return float4(i.localPosition + 0.5, 1);




局部坐标重新着色

我们也可以把_Tint作为系数应用进去。

return float4(i.localPosition + 0.5, 1) * _Tint;
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;}
ConstBuffer "$Globals" 128Vector 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




局部坐标乘以红色,因此只有X分量保留

贴图

如果你想让一个面片明显的添加一些细节和变化,如果不添加三角形,那么你可以使用贴图。然后你将这张贴图映射到这个面片三角形上。

贴图坐标就是用来控制这个投影的。这些2D坐标对,覆盖整个图像在一个单位平方区域,无论纹理的实际纵横比如何。水平坐标叫U,竖直坐标叫V。它们因而通常也叫作UV坐标。




UV坐标的分布

U坐标从左到右增长。因此0在最左边,1/2是中间,1在最右边。V坐标同理作用在竖直方向上,从下到上,但在Direct3D里例外,Direct3D里面是从上到下。你不用担心这个不同。

使用UV坐标

Unity中的面片默认有一个UV坐标来匹配贴图映射。顶点程序可以访问到它们,通过带TEXCOORD0语义的参数。

Interpolators MyVertexProgram (                float4 position : POSITION,                float2 uv : TEXCOORD0            ) {                Interpolators i;                i.localPosition = position.xyz;                i.position = mul(UNITY_MATRIX_MVP, position);                return i;}

我们的顶点程序现在使用了超过1个的参数。再一次我们可以用我们的结构体来封装它们。

    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坐标直接传递到了片段程序中去,替换掉了局部坐标local position。

        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坐标变得可见,就像local position那样,把它们当做颜色通道。比如,U变成红色,V变成绿色,蓝色始终值为1。

            float4 MyFragmentProgram (Interpolators i) : SV_TARGET {                return float4(i.uv, 1, 1);            }

现在你可以看到,编译的顶点程序,把UV坐标从顶点数据传递到了插值器的输出了。

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;}
Bind "vertex" VertexBind "texcoord" TexCoord0ConstBuffer "UnityPerDraw" 352Matrix 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坐标包裹在其球体周围,将图像的顶部和底部折叠在极点处。 你将看到从北到南极的缝合线,图像的左侧和右侧连接在一起。 所以沿着这个接缝,你将具有0和1的U坐标值。这是通过沿着接缝具有重复的顶点来完成的,除了它们的U坐标之外是相同的。







UV作为颜色的前视图和顶视图

加入贴图

下面这张贴图用来测试贴图。




用于测试的贴图

你可以通过拖拽的方式将图片添加进入工程视图,也可以通过Asset/Import New Asset的方式。图片会导入成2D 体贴格式,带默认设置。







导入的带默认设置的贴图

要使用贴图,我们需要添加另外一个 shader 属性,常规的 shader 属性类型为2D,除了2D 雷翔外也有其他的贴图类型。默认的贴图属性的值是一个字符串,这个字符串其实应用的是 Unity默认的贴图(内置在引擎中的),要么是白色(white),要么是黑色(black),或者灰色(gray)。

main texture 惯用的命名是_MainTex,所以我们就使用这个命名。这也让我们能够很方便的通过脚本使用Material.mainTexture属性来访问到主贴图。

    Properties {        _Tint ("Tint", Color) = (1, 1, 1, 1)        _MainTex ("Texture", 2D) = "white" {}    }

shader 属性的值后面的花括号是干什么用的?

过去有的贴图设置是针对于老师的固定管线(fixed function)shader 的,但是现在没有使用了。这些设置会被放进这个花括号里面。虽然他们现在没有用了,但是 shader 编译器仍然会去检测它,如果删除它会报错的。也许后续的 Unity 版本会有所改变,让这种写法也能编译通过。

现在我们可以给我们的材质球赋值上这张贴图。




把贴图赋值给材质球

我们可以使用一个 sampler2D类型的变量来在我们的 shader 中访问这张贴图。

float4 _Tint;sampler2D _MainTex;

用UV 坐标来对贴图进行采样,其实发生在片段程序中,通过 tex2D这个函数。tex2D采样函数的第一个参数是贴图,第二个参数是uv。

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {          return tex2D(_MainTex, i.uv);}
uniform sampler2D _MainTex;in vec2 vs_TEXCOORD0;layout(location = 0) out vec4 SV_TARGET0;void main(){    SV_TARGET0 = texture(_MainTex, vs_TEXCOORD0.xy);    return;}
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

既然片段程序对贴图进行了残阳,那么它就会映射到球体上。正如我们预期的那样,贴图把整个球体给包住了,但是在极点处贴图变得特别歪斜,这是为什么呢?

贴图突然变形是因为在三角形之间插值是线性的。在这个极点附近仅仅只有几个三角形,而这几个三角形的 UV 是被扭曲得最厉害的。因此 UV 坐标从顶点到顶点是非线性变化的。因此贴图中的直线会突然在三角形的边界处改变方向。

不同的面片有不同的 UV 坐标,因此也产生了不同的映射效果。Unity 默认的球体使用的是经纬贴图映射方式,而面片是一个低精度的立方体球。用来测试它已经够用了,如果你想要有一个好的球体贴图效果,就只有使用自己分好的 UV 的球体了。

最后,我们还可以加上颜色 Tint作为一个系数来调整球体的外观

return tex2D(_MainTex, i.uv) * _Tint;

拼贴与偏移

当我们添加了一个贴图属性到我们的 shader 中去后,检视面板上除了多了一个贴图字段,而且还多了tiling 和 offset的可调节的区域。但是,现在去改变这些2D向量还没有任何效果。

这个额外的贴图数据其实存储在材质球中,当然也可以被shader 访问到。通过 shader 访问到 tiling和 offset,你可以通过一个变量,这个变量的名字是相关材质球的名字再加上**_ST作为后缀。这个变量的类型必须是 float4。
_ST是什么意思?
S 代表缩放(Scale),T 代表变换(Transformation)。那为什么不是 _TO呢,T 代表Tiling,O 代表 Offset?因为 Unity 一直使用的就是_ST,为了向后兼容,即使是专业术语改变了,也会保持原有的命名方式。

tiling 向量用于贴图的缩放,因此默认值是(1,1),它被存储在这个变量的XY 分量上。要使用这个 tiling,只需要用 UV 坐标乘上这个分量。这个操作既可以在顶点着色器中完成也可以在片段着色器。然而在顶点着色器中它才有意义,所以我们仅仅在顶点程序中乘上它。

            Interpolators MyVertexProgram (VertexData v) {                Interpolators i;                i.position = mul(UNITY_MATRIX_MVP, v.position);                i.uv = v.uv * _MainTex_ST.xy;                return i;            }

offset部分存储在这个变量的 ZW 分量上,用来滚动这个贴图。它需要在scaling之后,再加上它。

i.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;

UnityCG.cginc中包含了一个好用的宏来简化成一个模板供我们使用。

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

什么叫“宏”?

一个宏就像一个函数一样。不一样的是,宏执行在预处理阶段,这个阶段在真正的编译之前。因此就允许对代码文本进行篡改,比如追加一个_ST到一个变量的后面。这个**TRANSFORM_TEX**宏就使用了这种技巧。如果你好奇,这里是它的定义。#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)宏让我们可以使用各种各样的技巧,但这也导致出现一些很难理解的代码和糟糕的 bug。这也是为什么 C#中没有宏。在后续的教程中,我们会创建我们自己定制的宏。

贴图设置

到目前为止我们还是使用的一个默认的贴图设置。让我们来看看几个选项,看看他们到底是拿来做什么的。

Wrap Mode指示当采用位于0-1范围之外的UV坐标进行采样时会发生什么。 当Wrap Mode设置为Clamp(夹紧)时,UV被限制在0-1范围内。 这意味着超出边缘的像素与位于边缘上的像素相同。 当包装模式设置为Repeat(重复)时,UV包裹着整张贴图。 这意味着边缘以外的像素与纹理相反侧的像素相同。 默认模式是Repeat 模式,这种模式会触发tile(拼贴)。

如果你不想去平铺一张贴图的话,你会想到去夹住UV坐标来代替Repeat。这个就避免了贴图重复,取而代之是纹理的边界会不断的重复,以至于看起来像被拉伸了一样。




贴图模式为夹紧(calmped)下,平铺设置成(2,2)的效果

Mipmaps和Filtering

当贴图的纹理元素的像素不匹配它们所投射的像素?这种不匹配必须用某种方式来解决,Filter Mode (过滤模式)就是来干这个事的。
最简单的过滤模式是Point(无过滤器)。 这意味着当在一些UV坐标采样纹理时,使用最近的纹理。 这将给纹理一个块状的外观,除非纹素贴图精确地映射到显示像素。 因此,它通常用于像素完美渲染,或者当需要块状时。

默认是使用双线性过滤。 当纹理在两个纹理元素之间的某处被采样时,这两个纹理元素进行插值。 由于贴图是2D的,因此沿着U轴和V轴都会发生。 因此双线性滤波,而不仅仅是线性滤波。

当纹理元素的密度小于显示像素密度时,此方法起作用。因此当你放大到纹理时, 结果会看起来模糊。 相反,当你缩小纹理时,它不起作用。 相邻的显示像素将以多于一个纹素的样本结束。 这意味着纹理的部分将被跳过,这会造成一个粗糙的过渡,就像图像被锐化了一样。

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







带各向异性和不带各向异性的区别

mipmap是什么意思?

mipmap这个词是MIP地图的缩写。字母MIP代表了parvo的拉丁语中的“multum”,意思是在一个小空间里有很多人。这是由Lance Williams在他第一次描述mipmapping技术时创造出来的。

当然你可以禁用mipmap。 首先,将“贴图类型”(Texture Type)类型设置为“高级”(Advanced)。 他们可以禁用mipmap并应用更改。 看到差异的一个好方法是使用像四边形的平面对象,从同一个角度去看它。

    有 mipmap    无 mipmap

那么在哪里使用哪一个 mipmap 等级,他们看起来有多不同? 我们可以通过在高级纹理设置中启用Fadeout Mip Maps来使过渡可见。 启用时,检测器中将显示淡入淡出范围滑块。 它定义了mipmap范围,mipmap将通过该范围转换为纯灰色。 通过使此转换成为一个步骤,您将获得一个急剧的过渡到灰色。 进一步你将一步范围向右移动,过渡将会发生。

过渡到灰色有什么用?

它用来实现纹理的细节,我们以后的教程会涉及到。你可能会想到这也许是拿来实现雾效的,但是这并不正确。mipmap基于纹理元素与显示像素密度的比较而使用的,所以并不是根据3D 的距离而来的。

为了有一个更好的视觉效果,现在可以把贴图的Aniso Level设置为0。

一旦你知道各种mipmaps水平在哪里,你应该可以看到他们之间纹理质量的突然变化。 随着纹理投影变小,纹理密度增加,这使得它看起来更加清晰。 直到下一个mipmap级别突然出现,再次变得模糊。

所以不使用mipmap你从模糊到锋利,太尖锐。 使用mipmaps,你从模糊到锐利,再次突然模糊,锐利,再次突然模糊等等。
那些模糊尖锐的波段是双线性滤波的特征。 你可以通过将过滤器模式切换为三线性来摆脱它们。 这与双线性滤波相同,但它也在相邻mipmap级之间进行插值。 因此三线性。 这使得采样更加昂贵,但它平滑了mipmap级别之间的转换。

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

各向异性是什么意思?

大致来说,当某些东西在不同方向看起来相似时,它是各向同性的。 例如,一个没有什么特征的立方体。当不是这种情况时,它是各向异性的。 例如,一块木头,因为它的纹理沿一个方向而不是另一个方向。

当一个纹理以一个角度投影时,由于透视,你经常会遇到其中一个维度被扭曲得比另一个更大。 一个很好的例子是纹理接地平面。 在距离上,纹理的前后尺寸将显示出比左右尺寸小得多的尺寸。

注意,这些额外的mipmap不像常规mipmap那样预先生成。 而是通过执行额外的纹理采样来进行模拟的。 所以他们不需要更多的空间,但是要花费更多的代价。




各向异性双线性滤波,过渡到灰色

各向异性过滤的深度由Aniso Level控制。 在0时,它被禁用。 在1,它变得启用并提供最小的效果。 在16时,它是最大的。 但是,这些设置会受到项目Quality Setting的影响。
您可以通过Edit / Project Settings / Quality进行设置。 您将在“Rendering”部分找到各向异性纹理设置。




渲染质量设置

当禁用各向异性纹理时,无论纹理的设置如何,都不会发生各向异性过滤。 当它设置为每个纹理,它完全由每个单独的纹理控制。 它也可以设置为强制开,这将像每个纹理的Aniso Level设置为至少为9一样。但是,Aniso Level设置为0的纹理仍然不会使用各向异性过滤。

原创粉丝点击