到目前为止,我们一直依赖于表面着色器。它们被设计用来简化着色器编码的工作方式,为艺术家提供有意义的工具。如果我们想进一步了解着色器的知识,我们需要冒险进入顶点着色器和片段着色器的领域。
与表面着色器相比,顶点着色器和片段着色器几乎没有关于物理属性的信息,这些物理属性决定了光如何反射到表面上。他们缺乏表达能力,却用力量来弥补。顶点和片段着色器不受物理约束的限制,非常适合非真实感效果。本章将关注一种叫做Grab通道的技术,它允许这些着色器模拟变形。
在本章中,我们将介绍以下食谱:
- 理解顶点和片段着色器
- 使用抓取通道绘制物体后面
- 实现玻璃着色器
- 实现2D游戏的水着色器
理解顶点和片段着色器
要了解顶点着色器和片段着色器的工作原理,最好的方法是自己创建一个。这个配方将向你展示如何编写这些着色器之一,它将简单地应用纹理到一个模型,并乘以一个给定的颜色,如下面的截图所示:
注意它的工作原理与Photoshop中的Multiply滤镜工作原理相似。这是因为我们要做的计算和刚才做的一样!
这里展示的着色器非常简单,它将被用作所有其他顶点和片段着色器的开始基础。
准备
对于这个食谱,我们需要一个新的着色器。遵循以下步骤:
- 新建一个着色器(Multiply)。
- 创建一个新的材质(MultiplyMat),并将着色器分配给它。
- 从Chapter 07 | Prefabs 文件夹中取出士兵预制件到场景中,并将新材料附加到预制件的头部。为此,从Hierarchy窗口中,选择Soldier对象的Soldier子对象。
- 从那里,在Inspector选项卡中,向下滚动到蒙皮网格渲染器组件,在Materials下面,设置Element 0 为 new materials:
- 最后,在反照率(RGB)属性中,拖放Unity_soldier_Head_ DIF_01纹理。下面的截图展示了我们正在寻找的.
怎么做……
在之前的所有章节中,我们总是能够改装表面着色器。现在情况不同了,因为表面着色器和碎片着色器在结构上是不同的。我们需要实现以下更改:
- 删除着色器的所有属性,用以下内容替换它们:
Properties { _Color ("Color", Color) = (1, 0, 0, 1) _MainTex ("Albedo (RGB) ", 2D) = "white" {} }
- 删除SubShader块中的所有代码,并将其替换为:
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
half4 _Color;
sampler2D _MainTex;
struct vertInput
{
float4 pos : POSITION;
float2 texcoord : TEXCOORD0;
};
struct vertOutput
{
float4 pos : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
vertOutput vert(vertInput input)
{
vertOutput o;
o.pos = UnityObjectToClipPos(input.pos) ;
o.texcoord = input. texcoord;
return o;
}
half4 frag(vertOutput output) : COLOR
{
half4 mainColour = tex2D(_MainTex, output. texcoord) ;
return mainColour * _Color;
}
ENDCG
}
}
FallBack "Diffuse"
- 保存着色器脚本并返回到Unity编辑器。完成后,修改MultiplyMat材质的Color属性。你会看到我们得到了我们想要的结果:
这也将是所有未来顶点和片段着色器的基础
它是如何工作的……
顾名思义,顶点着色器和片段着色器分两个步骤工作。模型首先通过一个顶点函数;然后将结果输入到片段函数。这两个函数都是使用#pragma指令分配的:
#pragma vertex vert
#pragma fragment frag
在这种情况下,它们被简单地称为vert和frag
从概念上讲,片段与像素密切相关; 术语片段通常用来指收集必要的数据来绘制一个像素。这也是为什么顶点着色器和片段着色器通常被称为像素着色器。
顶点函数接受一个结构中的输入数据,该结构在着色器中被定义为vertInput:
struct vertInput
{
float4 pos : POSITION;
float2 texcoord : TEXCOORD0;
}
它的名字是随意的,但它的内容不是。结构的每个字段必须用绑定语义装饰。这是Cg的一个特性,它允许我们标记变量,以便用特定的数据初始化它们,例如法向量和顶点位置。绑定语义POSITION表示当vertInput被输入到顶点函数中时,pos将包含当前顶点的位置。这类似于表面着色器中appdata_full结构的顶点字段。主要的区别是pos是用模型坐标表示的(相对于3D对象),我们需要手动转换为视图坐标(相对于屏幕上的位置)。
NOTE
曲面着色器中的顶点函数只用于改变模型的几何形状。然而,在顶点或片段着色器中,顶点函数是必要的,以在屏幕上投影模型的坐标。
这种转换背后的数学方法超出了本章的范围。然而,这种转换可以通过使用UnityObj ectToClipPos函数来实现,它将在齐次坐标中从对象的空间取一个点到相机的剪辑空间。这是通过乘以模型-视图-投影矩阵来实现的,这对于找到一个顶点在屏幕上的位置非常重要:
vertOutput o;
o. pos = UnityObjectToClipPos(input. pos) ;
NOTE
更多关于ShaderLab内置的辅助函数的信息,请查看https: //docs. unity3d. com/Manual/SL-BuiltinFunctions. html
另一个被初始化的信息是texcoord,它使用TEXCOORD0绑定语义来获取第一个纹理的UV数据。不需要进一步的处理,这个值可以直接传递给fragment函数(frag):
o.texcoord = input.texcoord;
当Unity为我们初始化vertInput时,我们负责初始化vertOutput。尽管如此,它的字段仍然需要用绑定语义来装饰:
struct vertOutput
{
float4 pos : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
一旦顶点函数初始化了vertOutput,该结构就被传递给片段函数(fragg)。这将对模型的主要纹理进行采样,并将其乘以所提供的颜色。
正如你所看到的,顶点着色器和片段着色器不知道材质的物理属性。这意味着材料不具有与光源相同的效果,它没有关于光如何反射创建碰撞表面的数据与表面着色器相比;它更接近图形处理器的架构。
有更多的…
顶点着色器和片段着色器最令人困惑的方面之一是绑定语义。你还可以使用其他许多词,它们的含义取决于上下文。
输入语义(Input semantics)
下表中显示的绑定语义可以在vertInput中使用,vertInput是Unity提供给顶点函数的结构。用这些语义装饰的字段将自动初始化:
输出的语义(Output semantics)
绑定时,vertOutput使用语义;它们不自动保证fields将被初始化。事实恰恰相反;这是我们的责任。编译器将尽力确保字段用正确的数据初始化:
NOTE
要了解更多关于剪辑空间、剪辑空间的含义以及DirectX和OpenGL之间的区别的信息,请查看https: //answers.unity. com/questions/1443941/shaders-what-is-clip-space. html
如果出于任何原因,您需要一个包含不同类型数据的字段,您可以使用众多可用的TEXCOORD数据中的一个来装饰它。编译器不允许未修饰的字段。
另请参阅
你可以参考NVIDIA参考手册来查看Cg中可用的其他绑定语义:http://developer.download.nvidia.com/cg/Cg_3.1/Cg-3.1_April2012_ReferenceManual.pdf
使用Grab通道在物体后面绘制
在第6章“基于物理渲染”的“为PBR添加透明度”中,我们学习了如何使材料透明。即使透明材料可以覆盖一个场景,它也不能改变在它下面绘制的东西。这意味着那些透明着色器不能创造扭曲,如典型的看到在玻璃或水。为了模拟它们,我们需要引入另一种技术,称为 grab pass。它允许我们访问已经在屏幕上绘制的内容,以便着色器可以不受限制地使用它(或更改它)。为了学习如何使用 grab pass,我们将创建一个材料来抓取后面渲染的内容,并在屏幕上再次绘制它。矛盾的是,它是一个着色器,执行几个操作来显示完全没有变化。
准备
对于这个食谱,你需要做以下操作:
- 创建一个我们稍后将初始化的着色器(GrabShader)。
- 创建一个承载着色器的材质(GrabMat)。
- 将材料附着在一个flat的几何图形上,例如四边形。把它放在另一个物体的前面,这样你就不能透过它看到东西了。当着色器完成时,四边形就会显示为透明的:
怎么做……
要使用grab pass,请遵循以下步骤:
- 删除属性部分,因为这个着色器不会使用它。
- 在SubShader部分,删除所有内容,这样你就剩下了下面的着色器代码
Shader "CookbookShaders/Chapter 08/GrabShader" { SubShader { } FallBack "Diffuse" }
- 接下来,添加以下到SubShader部分,以确保对象被视为透明的:
Tags{ "Queue" = "Transparent" }
- 然后,在下面,添加一个grab pass:
GrabPass{ }
- 在GrabPass之后,我们需要添加以下额外的pass:
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG. cginc"
sampler2D _GrabTexture;
struct vertInput
{
float4 vertex : POSITION;
};
struct vertOutput
{
float4 vertex : POSITION;
float4 uvgrab : TEXCOORD1;
};
// Vertex function
vertOutput vert(vertInput v)
{
vertOutput o;
o. vertex = UnityObjectToClipPos(v. vertex) ;
o. uvgrab = ComputeGrabScreenPos(o. vertex) ;
return o;
}
// Fragment function
half4 frag(vertOutput i) : COLOR
{
fixed4 col = tex2Dproj(_GrabTexture, UNITY_PROJ_COORD(i. uvgrab) ) ;
return col + half4(0. 5, 0, 0, 0) ;
}
ENDCG
}
- 保存脚本并返回到Unity编辑器。在这一点上,你应该看到你的材料现在以你想要的方式工作:
它是如何工作的……
到目前为止,我们的代码总是直接放在SubShader部分。这是因为我们之前的着色器只需要一次Pass。这一次,需要两个Pass。第一个是GrabPass{},它由GrabPass{}定义。其余的代码放在第二次Pass中,它包含在pass块中。
第二个Pass在结构上与本章第一个配方中展示的着色器没有什么不同;我们使用顶点函数,vert,来获得顶点的位置,然后我们在Fragment函数中给它一个颜色。不同之处在于,vert计算了另一个重要的细节:GrabPass{}的UV数据。GrabPass{}自动创建一个可以引用如下的纹理:
sampler2D _GrabTexture;
为了对这个纹理进行采样,我们需要它的UV数据。ComputeGrabScreenPos函数返回数据,我们可以稍后使用它来正确地采样抓取纹理。这是在Fragment Shader中使用以下代码完成的:
fixed4 col = tex2Dproj (_GrabTexture, UNITY_PROJ_COORD(i. uvgrab) ) ;
这是获取纹理并将其应用到屏幕正确位置的标准方法。如果一切都做得正确,这个着色器将简单地克隆在几何图形后面渲染的东西。在下面的食谱中,我们将如何使用这种技术来创建水和玻璃等材料。
有更多的…
每次你使用GrabPass{}材质时,Unity将不得不渲染屏幕到纹理。这个操作非常昂贵,并且限制了你可以在游戏中使用的GrabPass实例的数量。Cg提供了稍微不同的变化:
GrabPass {"TextureName"}
这一行不仅允许你给纹理命名,还允许你与所有有一个名为TextureName的GrabPass的材料共享纹理。这意味着如果你有10个材质,Unity将只执行一个GrabPass,并与所有材质共享纹理。这个技术的主要问题是它不允许可以堆叠的效果。如果你用这种方法制作一个玻璃杯,你就不可能有两个玻璃杯一个接一个。
实现玻璃着色器
玻璃是一种非常复杂的材料; 不应该感到惊讶的是,其他食谱已经创建了着色器来模拟它,如在第6章,基于物理渲染的添加透明度的PBR食谱。我们已经知道如何使我们的眼镜半透明,以完美地显示其背后的物体,这在许多应用中都是有效的。然而,大多数眼镜都不是完美的。例如,如果你透过彩色玻璃窗看,你可能会注意到变形。这张食谱将教你如何达到这种效果。这个效果背后的想法是使用一个顶点和片段着色器与GrabPass,然后通过改变它的UV数据来采样抓取纹理创建一个失真。你可以在下面的截图中看到这个效果。在这里,我们使用了Unity的标准资产中的玻璃着色纹理:
准备
这个食谱的设置类似于上一个食谱:
- 创建一个新的顶点和片段着色器。你可以从复制我们在前一个食谱中使用的方法开始,使用Grab pass来绘制对象的后面,作为一个基础,选择它并按Ctrl + D复制它。复制完成后,将其名称更改为WindowShader。
- 创建一个材质,将使用着色器(WindowMat)。
- 将材质分配到一个四边形或另一个平坦的几何体,将模拟您的玻璃。
- 在它后面放一些物体,这样你就可以看到扭曲效果了。
怎么做……
让我们从编辑顶点着色器和片段着色器开始:
-
在脚本的SubShader部分上面,创建一个Properties块,其中包含以下项目:
Shader "CookbookShaders/Chapter 08/WindowShader" { Properties { _MainTex("Base (RGB) Trans (A) ", 2D) = "white" {} _Colour("Colour", Color) = (1, 1, 1, 1) _BumpMap("Noise text", 2D) = "bump" {} _Magnitude("Magnitude", Range(0, 1) ) = 0. 05 } SubShader { Tags{ "Queue" = "Transparent" } // Rest of WindowShader. shader
-
在第二次Pass中添加它们的变量:
sampler2D _MainTex; fixed4 _Colour; sampler2D _BumpMap; float _Magnitude;
-
将纹理信息添加到vertInput和vertOutput结构中:
float2 texcoord : TEXCOORD0;
-
将UV数据从输入传输到输出结构:
// Vertex function vertOutput vert(vertInput v) { vertOutput o; o. vertex = UnityObj ectToClipPos(v. vertex) ; o. uvgrab = ComputeGrabScreenPos(o. vertex) ; o. texcoord = v. texcoord; return o; } ```
-
使用以下Fragment函数:
half4 frag(vertOutput i) : COLOR { half4 mainColour = tex2D(_MainTex, i.texcoord) ; half4 bump = tex2D(_BumpMap, i.texcoord) ; half2 distortion = UnpackNormal(bump).rg; i.uvgrab.xy += distortion * _Magnitude; fixed4 col = tex2Dproj (_GrabTexture, UNITY_PROJ_COORD(i.uvgrab) ) ; return col * mainColour * _Colour; }
-
这个材质是透明的,所以我们需要改变SubShader块的标签部分
Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }
-
保存脚本并返回到Unity编辑器。在场景窗口中,你可能看不到任何东西。我们现在需要做的是为玻璃设置纹理,并使用法线贴图来替换GrabPass (你可以在本书示例代码的 Chapter 08 | Textures 文件夹中找到一个例子):
它是如何工作的……
; 这个着色器使用一个Grab pass 来获取已经呈现在屏幕上的内容。变形发生的部分是在片段函数中。在这里,一个法线映射被解压缩并用于偏移抓取纹理的UV数据。
half4 bump = tex2D(_BumpMap, i. texcoord) ;
half2 distortion = UnpackNormal(bump) . rg;
i. uvgrab. xy += distortion * _Magnitude;
_Magnitude滑块用于确定效果的强度:
有更多的…
这种效应非常普遍; 它抓住屏幕,根据法线贴图产生失真。我们没有理由不使用它来模拟更有趣的事情。许多游戏使用爆炸或其他科幻设备的扭曲。这种材质可以应用在球体上,通过不同的法线贴图,它可以完美地模拟爆炸的热浪。
实现2D游戏的水着色器
我们在前一个配方中介绍的玻璃着色器是静态的;它的失真从未改变。只需要做一些改变就能将其转换成动画材料,这使得它非常适合以水为特色的2D游戏。这使用了一个类似的技术,在第7章,顶点函数,在一个表面着色器的动画顶点配方中:
准备
这个配方是基于顶点和片段着色器描述在使用Grab pass绘制后面的对象配方,因为它将严重依赖于GrabPass。让我们开始吧:
- 创建一个新的顶点和片段着色器。你可以开始复制一个使用在前面的食谱,使用 Grab pass 来绘制后面的对象,作为一个基础(GrabShader),选择它并按Ctrl + D复制它。复制完成后,将其名称改为2DWaterShader。
- 创建一个材质,将使用着色器(2DWaterMat)。
- 将材质分配到一个平坦的几何将代表你的2D水。为了让这个效果起作用,你应该在它后面渲染一些东西,这样你就可以看到水一样的位移:
- 这个配方需要一个噪声纹理,用来获取伪随机值。你必须选择一个无缝的噪声纹理,比如由可平铺的2D柏林噪声生成的纹理,如下面的截图所示:
这可以确保当材料被应用到一个大的物体上时,你不会看到任何不连续。为了实现这个效果,必须导入纹理并将其Wrap Mode属性设置为Repeat。如果你想为你的水平滑和连续的外观,你也应该设置过滤模式为双线性从检查器窗口。这些设置确保纹理从着色器中正确采样。
怎么做……
要创建这个动画效果,你可以从改装着色器开始。遵循以下步骤:
- 添加以下属性:
Properties { _NoiseTex("Noise text", 2D) = "white" {} _Colour("Colour", Color) = (0. 67, 1, 0. 96, 1) _Period("Period", Range(0, 50) ) = 1 _Magnitude("Magnitude", Range(0, 0. 5) ) = 0. 05 _Scale("Scale", Range(0, 10) ) = 1 }
- 将它们各自的变量添加到第二次pass 着色器中:
#include "UnityCG. cginc" sampler2D _GrabTexture; sampler2D _NoiseTex; fixed4 _Colour; float _Period; float _Magnitude; float _Scale;
- 为顶点函数定义以下输入和输出结构:
struct vertInput { float4 vertex : POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct vertOutput { float4 vertex : POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPos : TEXCOORD1; float4 uvgrab : TEXCOORD2; };
- 这个着色器需要知道每个片段空间的确切位置。要做到这一点,更新顶点函数如下:
// Vertex function vertOutput vert(vertInput v) { vertOutput o; o.vertex = UnityObjectToClipPos(v.vertex) ; o.color = v.color; o.texcoord = v.texcoord; o.worldPos = mul(unity_ObjectToWorld, v.vertex) ; o.uvgrab = ComputeGrabScreenPos(o.vertex) ; return o; }
- 使用以下Fragment函数:
fixed4 frag(vertOutput i) : COLOR { float sinT = sin(_Time.w / _Period) ; float2 xyOverScale = i.worldPos.xy / _Scale; float2 xCoords = xyOverScale + float2(sinT, 0) ; float2 yCoords = xyOverScale + float2(0, sinT) ; float distX = tex2D(_NoiseTex, xCoords) . r - 0. 5; float distY = tex2D(_NoiseTex, yCoords) . r - 0. 5; float2 distortion = float2(distX, distY) ; i.uvgrab.xy += distortion * _Magnitude; fixed4 col = tex2Dproj (_GrabTexture, UNITY_PROJ_COORD(i. uvgrab) ) ; return col * _Colour; }
- 保存脚本并返回到Unity编辑器。然后,选择你的水材料(WatMat),并应用噪声纹理。然后,调整水材料的属性,注意它是如何改变它背后的东西的:
它是如何工作的……
这个着色器非常类似于在实现玻璃着色器配方中介绍的着色器。主要的区别是,这是一个动画材料;位移不是从法线映射生成的,而是考虑到当前时间来创建恒定动画。替换抓取纹理的UV数据的代码似乎相当复杂;让我们试着理解它是如何产生的。它背后的思想是一个正弦函数被用来使水振荡。这种影响需要随着时间的推移而演变;为了达到这个效果,由着色器产生的失真取决于当前时间,通过内置的_Time变量检索。_Period变量决定了正弦信号的周期,这意味着波出现的速度:
float2 distortion = float2( sin(_Time.w/_Period) , sin(_Time.w/_Period) ) – 0.5;
这个代码的问题是在x轴和y轴上有相同的位移; 因此,整个抓取纹理将以圆形运动旋转,这看起来一点也不像水。我们需要添加一些随机性。
向着色器添加随机行为最常见的方法是包含噪声纹理。现在的问题是找到一种方法在看似随机的位置对纹理进行采样。避免看到明显的正弦波模式的最好方法是在_NoiseTex纹理的UV数据中使用正弦波作为偏移量。
float sinT = sin(_Time. w / _Period) ;
float2 distortion = float2(
tex2D(_NoiseTex, i. texcoord / _Scale + float2(sinT, 0)).r - 0. 5,
tex2D(_NoiseTex, i. texcoord / _Scale + float2(0, sinT)).r - 0. 5
) ;
_Scale变量决定了波的大小。这个解决方案更接近于最后的版本,但有一个严重的问题——如果水的四边形移动,UV数据会跟随它,你可以看到水波跟随材料,而不是固定在背景上。为了解决这个问题,我们需要使用当前片段的世界位置作为UV数据的初始位置:
float sinT = sin(_Time. w / _Period) ;
float2 distortion = float2(
tex2D(_NoiseTex, i. worldPos.xy / _Scale + float2(sinT, 0)).r - 0.5,
tex2D(_NoiseTex, i. worldPos. xy / _Scale + float2(0, sinT)).r - 0.5
) ;
i.uvgrab.xy += distortion * _Magnitude;
结果是一个愉快的,无缝的失真,没有任何明确的方向移动。
我们还可以通过将失真分解为更小的步骤来提高代码的可读性。
float sinT = sin(_Time. w / _Period) ;
float2 xyOverScale = i.worldPos.xy / _Scale;
float2 xCoords = xyOverScale + float2(sinT, 0) ;
float2 yCoords = xyOverScale + float2(0, sinT) ;
float distX = tex2D(_NoiseTex, xCoords).r - 0. 5;
float distY = tex2D(_NoiseTex, yCoords).r - 0. 5;
float2 distortion = float2(distX, distY) ;
i.uvgrab.xy += distortion * _Magnitude;
这就是您应该在最终结果中看到的结果
NOTE
与所有这些特效一样,没有完美的解决方案。这个配方展示了一种你可以用来创造水一样失真的技术,但鼓励你玩它,直到你找到一个符合你的游戏美学的效果。