Unity Shaders and Effets Cookbook

《着色器和屏幕特效制作攻略》

这本书可以让你学习到如何使用着色器和屏幕特效让你的Unity工程拥有震撼的渲染画面。

                                                                                                       —— Kenny Lammers


第二章:纹理特效的应用

 介绍

第1节. 通过修改UV坐标实现纹理滚动效果

1.1、准备工作

1.2、如何实现...

1.3、实现原理...

第2节. 精灵表动画

2.1、准备工作

2.2、如何实现...

2.3、实现原理...

2.4、更多内容

2.5、另请参阅

第3节. 封装混合纹理贴图

3.1、准备工作

3.2、如何实现...

3.3、实现原理...

3.4、另请参阅

第4节. 法线贴图

4.1、准备工作

4.2、如何实现...

4.3、实现原理...

第5节. 在Unity编辑器中创建程序纹理

5.1、准备工作

5.2、如何实现...

5.3、实现原理...

第6节. Photoshop色阶特效

6.1、准备工作

6.2、如何实现... 

6.3、实现原理...

6.4、另请参阅


I like this book!


第二章:纹理特效的应用

Using Textures for Effects

        在本章中,我们开始研究如何使用纹理在着色器中创建不同的效果。正如我们在前一章看到的,纹理可以帮助我们实现更复杂的照明效果。我们也可以使用纹理来制作动画,进行混合,以及使用我们自定义的属性来控制它。在本章中,我们将学习以下方法:

 介绍

        纹理可以给我们的着色器带来生命,很快就能达到非常逼真的效果。遗憾的是,你需要小心处理你的Shader中贴图的数量,因为想要增加纹理的采样数量是很容易做到的。纹理采样的越多对性能的开销就越大。尤其是对于移动端上的优化来说,纹理的数量保持在最低限度对于在移动端上的优化来说是非常重要的解决方案。这样你的应用程序才能下载得更快,运行得更快。

        纹理本身通常在编辑图像的应用程序(如Photoshop)中创建的图像,但也可以在Unity内部创建。把纹理映射到了物体的表面,是通过使用对象的uv来把uv中的2D点与顶点的3D点之间联系了起来。然后在对象的顶点之间插入像素值(插值计算),以创建2D图像映射到3D表面的错觉。

        我们已经在上一章中设置了纹理属性,所以我们不用再重写一遍了,但是如果你想了解更多关于纹理如何映射到3D表面的内部工作原理,你可以阅读以下链接内容:The Cg Tutorial - Chapter 3. Parameters, Textures, and Expressionsicon-default.png?t=O83Ahttp://http.developer.nvidia.com/CgTutorial/cg_tutorial_chapter03.html        让我们先来看看我们可以用纹理做些什么,以及它们如何使我们的实时3D视觉效果变得更加有趣和引人注目。本章将从一些非常基础的纹理效果开始,然后带您进入更高级的材质纹理和着色器世界。

第1节. 通过修改UV坐标实现纹理滚动效果

        当今游戏行业中,在物体表面上做纹理滚动的效果是非常常见的一项技术。我们可以使用这项技术去创建一些特效,比如瀑布、河流、熔岩流动等效果。这也是去创建动画精灵效果的一个基础技术,动画精灵将会在下一章去来介绍。首先让我们来看看如何在表面着色器中创建一个简单的滚动效果。

1.1、准备工作

        在开始讲解这个技术前,你需要创建一个新的着色器文件(Shader)和一个新的材质球?(Material)。我们使用一个新创建的默认基础着色器来制作纹理的滚动效果。新创建的着色器里面的代码很干净,这有利于我们清晰的去展示新填写的代码步骤和过程。

1.2、如何实现...

        首先,我们打开刚刚创建的新着色器文件(Shader文件),并输入以下步骤中提到的代码:

  • 步骤1:需要在Shader中的Properties代码块中添加两个新的属性,用来控制纹理滚动的速度。这俩个属性分别控制了X方向和Y方向的速度。如下面的代码所示:
_MainTint("Diffuse Tint", color) = (1,1,1,1)
_MainTex("Base (RGB)", 2D) = "white"{}

// 添加下边两个属性
_ScrollXSpeed("X Scroll Speed", Range(0,10)) = 2
_ScrollYSpeed("Y Scroll Speed", Range(0,10)) = 2
  • 步骤2:修改CGPROGRAM代码中的Cg属性,并创建新的变量(变量名需要和Properties代码块中的属性变量名保持一致)。以便我们可以访问到Properties代码块中属性的变量值:
half4 _MainTint;
float _ScrollXSpeed;
float _ScrollYSpeed;
sampler2D _MainTex;
  • 步骤3:修改表面着色器(surf)函数里面传递给tex2D()函数的uv值。然后使用内置的_Time变量和Uv值进行计算,_Time变量可以让Uv动起来。在Unity编辑器中按运行键(Play键),调节材质球上的两个材质参数X Scroll Speed 和 Y Scroll Speed(材质球上的这两个参数实际上就是Shader中的_ScrollXSpeed和 _ScrollYSpeed的两个变量值),随着时间值的变化我们就可以看到uv动画了。代码如下:
void surf (Input IN, inout SurfaceOutput o)
{
    // 创建一个单独的变量来存储我们UV
    // 在将它们传递给给tex2D()函数之前
    half2 scrolledUv = IN.uv_MainTex;

    // 创建储存y和x的变量
    // 使用内置的时间变量(_Time)缩放uv组件
    half xScollValue = _ScrollXSpeed * _Time;
    half yScollValue = _ScrollYSpeed * _Time;

    // 得到最终的Uv偏移量
    scrolledUv += half2(xScollValue, yScollValue);

    // 将得到的Uv偏移量结果应用到采样的贴图上
    half4 c = tex2D (_MainTex, scrolledUv);
    o.Albedo = c.rgb;
    o.Alpha = c.a;
}
  • 完整代码如下
Shader "CookbookShaders/Using Textures for Effects-One"
{
    Properties 
    {
        _MainTint("Diffuse Tint", color) = (1,1,1,1)
        _MainTex("Base (RGB)", 2D) = "white"{}
        _ScrollXSpeed("X Scroll Speed", Range(0,10)) = 2
        _ScrollYSpeed("Y Scroll Speed", Range(0,10)) = 2
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma surface surf Lambert

        half4 _MainTint;
		float _ScrollXSpeed;
        float _ScrollYSpeed;
        sampler2D _MainTex;

        struct Input 
        {
            float2 uv_MainTex;
        };
        
        void surf (Input IN, inout SurfaceOutput o) 
        {
            // 创建一个单独的变量来存储我们UV 
            // 在将它们传递给给tex2D()函数之前
            half2 scrolledUv = IN.uv_MainTex;

            // 创建储存y和x的变量
            // 使用内置的时间变量(_Time)缩放uv组件
            half xScollValue = _ScrollXSpeed * _Time;
            half yScollValue = _ScrollYSpeed * _Time;

            // 得到最终的Uv偏移量
            scrolledUv += half2(xScollValue, yScollValue);

            // 将得到的Uv偏移量结果应用到采样的贴图上
            half4 c = tex2D (_MainTex, scrolledUv);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        下 图1.1展示了利用滚动UV系统为你的环境创建一个简单的河流运动的结果。在书中无法表现出运动的画面,所以你必须自己在Unity编辑器中写上如上的完整代码,并运行调节参数之后即可看到流动效果。

 图1.1

1.3、实现原理...

        滚动系统(Scrolling System)是从几个属性的声明开始的,我们可以在材质球参数面板上来进行调节,增加或减少Scrolling效果本身的速度。它们的核心是浮动值,通过从材质球检查器(即材质球的参数面板-Material Inspector)面板传递到Shader的表面着色器函数中。有关漫反射着色器属性的更多信息,请参见第1章,漫反射着色器(Diffuse Shading)。

        一旦我们在材质球的参数面板上有了这些浮动值,我们就能使用它们来替代Shader中的UV值去做偏移。  

        开始这个过程之前,我们先将uv存储在一个名为scrolledUV的变量中。这个变量必须是二维的(float2 / half2),因为UV值是从Input结构中传递给我们的。如下所示:

struct Input

{

        float2 uv_MainTex;

};

        一旦我们访问到了网格的Uv,我们就可以使用滚动速度(Scrolling Speed)变量和内置_Time变量来和它进行计算,计算得到的结果可以反过来替换掉访问的网格Uv。 _Time这个内置变量返回的变量是float4类型,这意味着该变量的每个组件包含了不同的时间值,因为它与游戏时间有关。 这些单独时间值的完整描述,详情见以下链接: 

http://docs.unity3d.com/Documentation/Components/SL-BuiltinValues.htmlicon-default.png?t=O83Ahttp://docs.unity3d.com/Documentation/Components/SL-BuiltinValues.html

        这个_Time变量会根据Unity的游戏时间时钟为我们提供了一个递增的浮点值。 因此,我们可以使用这个值在UV方向上移动UV,并使用滚动速度变量来缩放时间: 

// 创建储存y和x的变量

// 使用内置的时间变量(_Time)缩放uv组件

half xScollValue = _ScrollXSpeed * _Time;

half yScollValue = _ScrollYSpeed * _Time;

         通过时间计算出正确的偏移量,我们可以将新的偏移值添加回原始UV位置。 这就是我们在下一行使用了 “ += ” 操作符的原因。 我们想要获取原始的UV位置,添加新的偏移值,然后将其作为纹理的新UV传递给tex2D函数。 这样就做到了纹理在表面上移动的效果。 我们实际上是在控制uv,所以我们是模拟了纹理移动的效果。 

 // 得到最终的Uv偏移量

scrolledUv += half2(xScollValue, yScollValue);

// 将得到的Uv偏移量结果应用到采样的贴图上

half4 c = tex2D (_MainTex, scrolledUv);

第2节. 精灵表动画

        学习如何把精灵表制作成动画,以后一定会派上用场的。 它可以用于粒子效果或翻页书效果,常用于2D横向卷轴游戏。 

【精灵表,如果你不熟悉这个术语,也可以把它叫做精灵图集,它是一个包含许多较小图像的大纹理,有时也被称为图像序列。如下 图2.1 

 图2.1

        当你在表格上轮流滚动这些小图像时,你会看到里面的内容运动起来的效果。这个概念类似于用便签本做一个翻页书或者电影中使用逐帧胶卷制作的影片。如果我们在精灵表中循环遍历每一帧,我们就会创建一个动画的效果。 
        这个方法的代码会用到比较多的数学计算,但不用担心; 我们会逐步贯穿每一行新的代码,并对其进行彻底的解释。 

2.1、准备工作

        为了去来测试代码运行之后的效果,我们需要一些美术资源。我们要么自己制作一个精灵表,要么在互联网上找到一个。 精灵表不需要很复杂,它只需要一系列连贯的动作图像,如图2.2所示。 这本书中也包含了这个精灵表,精灵表页面地址:www.packtpub.com/support。

图2.2

        创建一个新的材质球(Material)和一个新的着色器(Shader),并把Shader赋予给材质球。 然后在场景中创建一个平面,把新创建的材质球赋予到平面上。再将精灵表放到材质球的纹理样本中。

2.2、如何实现...

        在下步骤中通过输入代码来获得我们的精灵动画着色器: 

  • 步骤1:在Shader的properties代码块中创建三个新属性。 这会帮助我们从材质球的检查器(Inspector)面板中修改系统,无需硬编码值: 
Properties
{
    _MainTex("Base (RGB)", 2D) = "white"{}

    // 创建下面的属性
    _TexWidth("Sheet Width", float) = 0.0
    _CellAmount("Cell Amount", float) = 0.0
    _Speed("Speed", Range(0.01,32)) = 12
}
  • 步骤2:然后将输入的uv存储到单独的变量中,以便我们可以处理这些值: 
// 让我们把Uv坐标储存在一个单独的变量中
float2 spriteUv = IN.uv_MainTex;
  • 步骤3:接下来我们需要得到每个单元格的宽度。 在精灵表中,这个值的范围是0到1,所以我们需要生成一个百分比值: 
// 在精灵表中计算数组中单元格的长度
// 并获得每个单元格占用的UV百分比
float cellPixelWidth = _TexWidth / _CellAmount;
float cellUVPercentage = cellPixelWidth / _TexWidth;
  • 步骤4:接下来,我们必须得到系统的时间组件。它能让我们去移动单元格,从一个单元格到另一个单元格,或者去控制UV的偏移值: 
// 让我们从时间中获取一个阶梯值,这样我们就可以递增了
// Uv偏移量
float timeVal = fmod(_Time.y * _Speed, _CellAmount);
timeVal = ceil(timeVal);
  • 步骤5:最后,我们创建偏移uv,我们可以提供精灵表的x方向。你现在拥有一个可以创建翻页书的着色器了。
// uv是通过单元格宽度的百分比,向前移动的
float xValue = spriteUv.x;
xValue += cellUVPercentage * timeVal * _CellAmount;
xValue *= cellUVPercentage;

// 得到了最终的uv坐标
spriteUv = float2(xValue, spriteUv.y);

// 将得到的Uv结果应用到采样的贴图上
half4 c = tex2D (_MainTex, spriteUv);
o.Albedo = c.rgb;
o.Alpha = c.a;
  • 完整代码如下
Shader "CookbookShaders/Animating sprite sheets"
{
    Properties 
    {
        _MainTex("Base (RGB)", 2D) = "white"{}
        // 创建下面的属性
        _TexWidth("Sheet Width", float) = 0.0
        _CellAmount("Cell Amount", float) = 0.0
        _Speed("Speed", Range(0.01,32)) = 12
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma surface surf Lambert

		float _TexWidth;
        float _CellAmount;
        float _Speed;
        sampler2D _MainTex;

        struct Input 
        {
            float2 uv_MainTex;
        };
        
        void surf (Input IN, inout SurfaceOutput o) 
        {
            // 让我们把Uv储存在一个单独的变量中
            float2 spriteUv = IN.uv_MainTex;

            // 在精灵表中计算数组中单元格的长度
            // 并获得每个单元格占用的UV百分比
            float cellPixelWidth = _TexWidth / _CellAmount;
            float cellUVPercentage = cellPixelWidth / _TexWidth;

            // 让我们从时间中获取一个阶梯值,这样我们就可以递增了
            // Uv偏移量
            float timeVal = fmod(_Time.y * _Speed, _CellAmount);
            timeVal = ceil(timeVal);
            
            // uv是通过单元格宽度的百分比,向前移动的
            float xValue = spriteUv.x;
            xValue += cellUVPercentage * timeVal * _CellAmount;
            xValue *= cellUVPercentage;

            spriteUv = float2(xValue, spriteUv.y);

            // 将得到的Uv结果应用到采样的贴图上
            half4 c = tex2D (_MainTex, spriteUv);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        下图2.3 是在表面着色器中对物体的uv进行偏移的结果。 又要强调一下,下边这张照片是运动的了,请相信我。因为在书中无法展示动画: 

图2.3 

2.3、实现原理...

        这个计算过程是,首先将输入结构体传递的uv存储到了一个单独的变量中。这一步并不是强制的,它属于个人习惯,而不是硬性规则——它只是一种阅读代码的方式。在本例中,我们将新的变量命名为spriteUV,并将其声明为二维的(float2)类型。 这是因为我们需要将网格uv的x和y值存储在一个变量中。

        下一步是获取当前纹理的宽度,并使用属性块中声明的_CellAmount属性将其分成更小的部分。 所以如果我们有一个宽度为512的纹理,我们把它分成16个单元格,我们会得到32的值。 这表示每个单元格宽度的像素数,但我们还需要知道每个单元格占用的百分比。 这是因为UV值总是在0到1,或0到100%的范围内计算的。 所以我们取cellPixelWidth变量并将它除以纹理本身的宽度。 如果我们将32像素的单元宽度除以512像素的纹理宽度,我们最终得到的值为0.06,即纹理总宽度的6%。 这代表了我们为了移动到精灵表的下一个单元格而需要偏移uv的值。

        接下来,我们需要计算一些随时间增长但为整数的值。 例如,一个值随着0、1、2、3、4等增加,直到它达到我们在精灵表中拥有的单元格总数。 为此,我们可以使用CGFX的内置函数fmod()。 

函数 函数的描述
fmod(x , y) 这将返回x / y的余数,其符号与x相同。如果y为0,则结果由实现定义。 

        如果我们将提供的x值输入到fmod()函数中并将其除以y的值,最后的返回值是该操作的余数。 所以,如果我们对x使用_Time值,对y使用_CellAmount属性值,我们将得到一个随时间增加的返回值,当它等于_CellAmount值时,它将重复。 

        生成该类型的值后,我们使用ceil()函数确保该值是整数,而不是小数。 这基本上是通过取一个数字比如1.5并将其强制变为2来实现的。 这将创建数字模式0,1,2,3,4,…直到_CellAmount属性下调节的数值为止。 一旦它达到单元格的值,它就从0重新开始。 

函数 描述
ceil(x) 其中最小的整数不小于x。 

        最后,我们从输入UV中获得当前的x值,计算如下:Uv中的x值 + 单元格的百分比 * 当前时间 * 总单元数。这个计算会把我们的UV从一个单元格移动到另一个单元格,但我们也必须缩放我们的UV值,以便在任何特定时间有一个单元格是可见的。 要做到这一点,我们只需将偏移UV的结果乘以单元格的百分比,这样我们就得到了最终的UV值。 所有需要做的就是将新的UV值传递给纹理的tex2D()函数的UV值。 


2.4、更多内容

        你可能已经看到了,但你不单单可以只使用一个偏移方向。 就像我们在之前的滚动uv方法中给出了两个方向的偏移量一样,这个方法让我们能拥有一个2D动画精灵表。 您只需将y的偏移量添加到最终的偏移量值中。

这与我们设置的水平滚动的原理相同,但现在你可以在多个维度上循环浏览更大的图像。 虽然这只是显示了你可以在着色器方面做的事情的数量,但它可能最终会在你的着色器中添加很多的着色器指令。 这意味着它比较消耗应用程序的性能。  

        为了解决这个问题,你可以借用C#代码作为媒介去控制Shader来执行帧的偏移。让CPU来驱动这部分代码。 当需要进行优化的时候,归根结底就是要平衡应用程序,但是我们预先去为将来遇到的元素定制好它们优化的方式,这样做是没有什么坏处的,毕竟你会围绕着遇到的元素来设计你的产品。本书包括一个c#脚本,演示如何使用脚本创建一个简单的精灵动画系统,并将数据传递给着色器。 它基本上为我们进行了时间的计算,并使用以下代码将时间值传递给着色器: 

void FixedUpdate()

{

        timeValue = Mathf.Ceil(Time.time % 16);

        transform.renderer.material.SetFloat("_TimeValue", timeValue);

}

2.5、另请参阅

        如果你不打算自己创建一个完整的精灵动画系统,那么Asset Store上有许多资源可以满足大多数(不是全部的)精灵动画的需求。 以下是其中一些资源的列表: 

  • SpriteManager (免费):

http://wiki.unity3d.com/index.php?title=SpriteManagericon-default.png?t=O83Ahttp://wiki.unity3d.com/index.php?title=SpriteManager

  • 2D 工具包 (Asset Store / $65.00):

http://www.unikronsoftware.com/2dtoolkit/icon-default.png?t=O83Ahttp://www.unikronsoftware.com/2dtoolkit/

  • Sprite Manager 2 (Asset Store / $150.00):

http://anbsoft.com/middleware/sm2/icon-default.png?t=O83Ahttp://anbsoft.com/middleware/sm2/     

        如果你正在寻找一个好的应用程序来帮助你制作精灵,这里有一些关于它们的信息,链接如下: 

  •  TimelineFX ($46.79):

Particle Effects for Games, Websites and More!Create particle effects for video games by exporting to sprite sheets. There is also C++, Blitzmax and Monkey libraries available for direct integration!icon-default.png?t=O83Ahttp://www.rigzsoft.co.uk/

  • Anime Studio Pro ($199.99):

http://anime.smithmicro.com/index.htmlicon-default.png?t=O83Ahttp://anime.smithmicro.com/index.html

  • Adobe Flash Professional ($699.00):

http://www.adobe.com/products/flash.htmlicon-default.png?t=O83Ahttp://www.adobe.com/products/flash.html

第3节. 封装混合纹理贴图

        纹理对于存储大量数据也很有用,不仅仅是我们通常认为的像素颜色,还可以在x和y方向以及RGBA通道中存储多组像素。 实际上,我们可以将多个图像打包成一个单一的RGBA纹理,并使用每个R, G, B和A通道作为单独独立的纹理,通过提取Shader代码中的每个R、G、B、A每个通道来获取它们。  

        将单个灰度图像打包成单个RGBA纹理的结果如下图3.1 所示:

图3.1 

        这样做对我们会有什么帮助?就应用程序占用的实际内存量而言,纹理会占据很大一部分应用程序的的。 所以,我们从一开始就要减少应用程序的大小,我们可以看看在我们的着色器中使用的所有图像,看看我们是否可以合并这些纹理成一个单一的纹理,来减少应用程序的大小。  

        任何灰度纹理都可以把它储存到另一个纹理的RGBA通道中任意一个通道里。 这听起来可能有点奇怪,但这个方法会为我们展示了混合纹理是什么样子的,以及在Shader中如何体现这些混合纹理的一个用途。  

        使用这些混合纹理举一个例子,就是当你想要在一个表面上混合一组纹理时。这种情况会经常出现在你的地形类型Shader中。在这种情况下,你需要使用某种控制纹理或填充纹理很好地融合到另一个纹理中。 这个方法就涵盖了这项技术,下面我们开始学习如何构建一个漂亮的四张纹理混合的地形Shader。

3.1、准备工作

        在你的Shader文件夹中创建一个新的Shader文件,然后为这个Shader创建一个新的材质球。它们的命名完全取决于这个着色器和材质球文件是用来做什么的,所以尽量保持它们有组织,便于以后引用。  

        准备好你的着色器和材质球之后,接下来创建一个新的场景,用来测试我们的着色器。  
 
        你还需要收集你想要混合在一起的四个纹理。 你可以混合任何纹理,但是对于一个好的地形着色器,你将需要一张草地,泥土,岩石泥土和岩石纹理。  

        这些都是颜色纹理,我们这个方法会使用到它们,这些纹理是包含在本书内的。如下图3.2所示:

 图3.2

        最后,我们还需要一个混合纹理,这个混合纹理包含的都是灰度图像。 同样提供了四种混合纹理,这张混合纹理的目的是用来指导如何将颜色纹理放置在物体表面上。

        我们可以使用非常复杂的混合纹理在地形网格上创建非常逼真的地形纹理分布,如下图3.3所示:

  图3.3

3.2、如何实现...

        下边让我们来开始学习,如何通过输入如下步骤中的代码来使用混合纹理:

  • 步骤1:我们需要在properties块中添加一些属性。 以下添加的是5个sampler2D对象或纹理和两个颜色的属性。  
Properties
{
    _MainTint("Diffuse Tint", color) = (1,1,1,1)

    // 添加下列属性,这样我们就可以输入所有的纹理
    _ColorA("Terrain Color A", color) = (1,1,1,1)
    _ColorB("Terrain Color B", color) = (1,1,1,1)
    _RTexture("Red Channel Texture", 2D) = ""{}
    _GTexture("Green Channel Texture", 2D) = ""{}
    _BTexture("Blue Channel Texture", 2D) = ""{}
    _ATexture("Alpha Channel Texture", 2D) = ""{}
    _BlendTex("Blend Texture", 2D) = ""{}
}
  • 步骤2:然后我们需要创建SubShader变量,这将是我们到Properties块中的数据的链接:  
CGPROGRAM
#pragma surface surf Lambert

// 在CGPROGRAM语句中声明以下变量
half4 _MainTint;
half4 _ColorA;
half4 _ColorB;
sampler2D _RTexture;
sampler2D _GTexture;
sampler2D _BTexture;
sampler2D _ATexture;
sampler2D _BlendTex;
  • 步骤3:现在我们有了纹理属性,我们将它们传递到SubShader function中。 为了允许我们可以去调节每一个纹理的平铺值和偏移值,我们需要修改Input结构,把贴图对应的uv添加进去。 这将允许我们在每个纹理上使用平铺和偏移的参数:  
struct Input
{
    float2 uv_RTexture;
    float2 uv_GTexture;
    float2 uv_BTexture;
    float2 uv_ATexture;
    float2 uv_BlendTex;
};
  • 步骤4:接下来在surf函数中,我们先将每个纹理进行采样,然后把它们存储到自己的变量中,这样我们的代码看起来就非常的干净整洁,这是以一种易于理解的方式来处理数据:  
// 从混合纹理中获取像素数据
// 因为采样的是贴图,我们这里需要给一个四维的变量类型 即float4
// 这里将返回R、G、B、A、或者X、Y、Z、W
float4 blendData = tex2D (_BlendTex, IN.uv_BlendTex);

// 从我们想要混合的纹理中得到数据
float4 rTexData = tex2D (_RTexture, IN.uv_RTexture);
float4 gTexData = tex2D (_GTexture, IN.uv_GTexture);
float4 bTexData = tex2D (_BTexture, IN.uv_BTexture);
float4 aTexData = tex2D (_ATexture, IN.uv_ATexture);
  • 步骤5:让我们使用lerp()函数将每个纹理混合在一起。 这个函数里面接受了三个参数,lerp(value: a, value: b, blend: c)。在lerp函数中,会把它接受的前两个纹理,与最后一个参数(float值)进行混合:
// 现在我们需要构造一个新的RGBA值,并添加所有制
// 将不同的混合纹理重新组合在一起
float4 finalColor;
finalColor = lerp(rTexData, gTexData, blendData.g);
finalColor = lerp(finalColor, bTexData, blendData.b);
finalColor = lerp(finalColor, aTexData, blendData.a);
finalColor.a = 1.0;
  • 步骤6:最后,我们将混合纹理与颜色色调值相乘,并使用红色通道来确定两种不同的地形色调的位置:  
// 添加地形着色颜色
float4 terrainLayers = lerp(_ColorA, _ColorB, blendData.r);
finalColor *= terrainLayers;
finalColor = saturate(finalColor);

o.Albedo = finalColor.rgb * _MainTint.rgb;
o.Alpha = finalColor.a;
  • 完整代码如下:
Shader "CookbookShaders/Packing and blending textures"
{
    Properties 
    {
        _MainTint("Diffuse Tint", color) = (1,1,1,1)

        // 添加下列属性,这样我们就可以输入所有的纹理
        _ColorA("Terrain Color A", color) = (1,1,1,1)
        _ColorB("Terrain Color B", color) = (1,1,1,1)
        _RTexture("Red Channel Texture", 2D) = ""{}
        _GTexture("Green Channel Texture", 2D) = ""{}
        _BTexture("Blue Channel Texture", 2D) = ""{}
        _ATexture("Alpha Channel Texture", 2D) = ""{}
        _BlendTex("Blend Texture", 2D) = ""{}
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma target 4.0 
        #pragma surface surf Lambert

        half4 _MainTint;
        half4 _ColorA;
        half4 _ColorB;
        sampler2D _RTexture;
        sampler2D _GTexture;
        sampler2D _BTexture;
        sampler2D _ATexture;
        sampler2D _BlendTex;

        struct Input 
        {
            float2 uv_RTexture;
            float2 uv_GTexture;
            float2 uv_BTexture;
            float2 uv_ATexture;
            float2 uv_BlendTex;
        };
        
        void surf (Input IN, inout SurfaceOutput o) 
        {
            // 从混合纹理中获取像素数据
            // 因为采样的是贴图,我们这里需要给一个四维的变量类型 即float4
            // 这里将返回R、G、B、A、或者X、Y、Z、W
            float4 blendData = tex2D (_BlendTex, IN.uv_BlendTex);

            // 从我们想要混合的纹理中得到数据
            float4 rTexData = tex2D (_RTexture, IN.uv_RTexture);
            float4 gTexData = tex2D (_GTexture, IN.uv_GTexture);
            float4 bTexData = tex2D (_BTexture, IN.uv_BTexture);
            float4 aTexData = tex2D (_ATexture, IN.uv_ATexture);

            // 现在我们需要构造一个新的RGBA值,并添加所有制
            // 将不同的混合纹理重新组合在一起
            float4 finalColor;
            finalColor = lerp(rTexData, gTexData, blendData.g);
            finalColor = lerp(finalColor, bTexData, blendData.b);
            finalColor = lerp(finalColor, aTexData, blendData.a);
            finalColor.a = 1.0;

            // 添加地形着色颜色
            float4 terrainLayers = lerp(_ColorA, _ColorB, blendData.r); 
            finalColor *= terrainLayers;
            finalColor = saturate(finalColor);

            o.Albedo = finalColor.rgb * _MainTint.rgb;
            o.Alpha = finalColor.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        将四个地形纹理混合在一起,并创建地形着色技术的结果如下图3.4所示: 

图3.4

 

3.3、实现原理...

        这个函数看起来使用的代码有点多,但是它背后的混合概念其实是非常简单的。我们为了让去实现该技术,就必须使用CGFX标准库中的内置函数,它就是lerp()函数。 这个函数需要我们在里面添加3个参数去来实现我们的混合效果,并且在参数1和参数2之间来选择一个值。 

函数 描述
lerp(a,b,f)

涉及线性插值:

(1 – f )* a + b * f    

这里,a和b是匹配的向量或标量类型。 F可以是标量,也可以是与a和b相同类型的向量。 

        因此,例如,如果我们想要使用lerp()函数来呈现参数1和参数2之间的中间值的效果,我们就可以将lerp()函数中的参数3的值输入为0.5,它将会返回1.5值这个值。 这可以完美地满足我们混合效果的需求,因为RGBA纹理中单个通道的值是单个浮点值(也就是都是一维的),并且它们通常都在0到1的范围内。  

        在Shader中,我们简单地从混合纹理中提取一个通道,并使用它来作为lerp函数中需要输入的参数值。例如,我们拿草地纹理贴图和泥土的纹理贴图,并使用混合纹理中的红色通道,将它们作为参数值输入lerp()函数中。 这会为表面上的每个像素提供正确的混合颜色结果。  

        下图3.5更直观的表示了使用lerp()函数进行混合之后,所展示的最终效果: 

图3.5

        Shader代码简单地使用混合纹理的四个通道(使用了4张灰度图进行混合的)和所有颜色纹理在Shader中来创建最终的混合纹理。 通过混合计算之后,这个最终显示出来的纹理成为我们的物体表面的颜色,我们可以使用漫反射光照模型与它相乘。

3.4、另请参阅

        这项技术中的地形是使用的World Machine创建的。 这是一个非常好的地形编辑器,并且它可以生成非常复杂的地形混合纹理,和各种的地形网格。 

        World Machine ($189.00):

World Machine: The Leading 3D Terrain Generation Softwareadvanced tools such as erosion algorithms and sophisticated colormaps to craft detailed terrain heightmaps, meshes, and textures for your games or 3D projects. Download World Machine for free today!icon-default.png?t=O83Ahttp://www.world-machine.com/

第4节. 法线贴图

        在当今的游戏开发流程中使用最常见的纹理技术之一就是法线贴图。 这使我们能够在低分辨率模型上模拟高分辨率几何图形的效果(使用低模来展示高模的细节)。 因为它并不是逐顶点进行的光照计算,而是使用法线贴图中的每个像素作为模型上的法线,法线贴图技术在光照的计算上提供了我们更多的分辨率,同时仍然保持了低模型的面数。  

  1. 在3D计算机图形学中,法线映射,或“Dot3凹凸映射”,是一种用于模拟凹凸和凹痕照明的技术——凹凸映射的实现。在使用低模的情况下可以展示高模的细节信息。该技术最常见的用途是从高模或者高度图中生成法线贴图,从而来极大的增强低模的外观和细节。
  2. 法线贴图通常存储的规则是一张RGB图像,其中RGB分量分别对应表面法线的X、Y和Z的坐标。  

        以上信息的文字引用来自于维基百科:http://en.wikipedia.org/wiki/Normal_mappingicon-default.png?t=O83Ahttp://en.wikipedia.org/wiki/Normal_mapping

现在有很多方法可以创建法线贴图。 比如以下的一些应用程序:CrazyBump和N2DO它们会把2D数据转换为法线数据。还有一些其他的应用程序如:Zbrush和Mudbox,这两款应用程序属于3D雕刻软件,可以使用它们来创建我们的法线贴图。实际上创建法线贴图已经超出了本书的范围,但是文本以上链接应该可以帮助您入门。

        Unity中使用了UnpackNormals()函数,通过这个函数,可以让我们很容易的在着色器中去来计算法线信息。接下来让我们看一看这是怎么做的。

        如果需要更深入的了解以下这些应用:CrazyBump、N2DO、Zbrush、Mudbox,下边是它们的相关链接分别如下所示:

        CrazyBump:

CrazyBumpicon-default.png?t=O83Ahttp://www.crazybump.com/        N2DO

http://quixel.se/ndo/icon-default.png?t=O83Ahttp://quixel.se/ndo/        Zbrush

http://www.pixologic.com/icon-default.png?t=O83Ahttp://www.pixologic.com/        Mudbox

http://usa.autodesk.comicon-default.png?t=O83Ahttp://usa.autodesk.com

4.1、准备工作

        创建一个新的材质球和一个新的着色器,并在场景中创建一个3DObject然后把材质球赋予给它。这样做的目的是为了有一个干净的工作空间,方便我们在里面测试法线映射技术。  

        首先我们需要一张法线贴图,本书的Unity项目中包含了一张法线贴图。法线贴图示例如下图4.1所示: 

图4.1

4.2、如何实现...

  • 步骤1:让我们在属性代码块中添加颜色和法线贴图两个属性信息:
Properties 
{
    _MainTint("Diffuse Tint", color) = (1,1,1,1)
    _NormalTex("Normal Map", 2D) = "bump"{}
}
  • 步骤2:在CGPROGRAM语句下面的Subshader中声明以下属性,以便我们后续的调用:  
// 将属性链接到CG程序
half4 _MainTint;
sampler2D _NormalTex;
  • 步骤3:为了将模型的uv用于法线贴图纹理上,我们需要修改Input结构体中的变量名,和我们采样的法线贴图相对应上。
// 确保在struct中获得法线纹理的uv
struct Input 
{
    float2 uv_NormalTex;
};
  •  步骤4:最后,我们使用内置的函数UnpackNormal()从法线贴图纹理中提取法线信息。 然后你只需要将这些新的法线应用到表面着色器的输出:  
// 从法线贴图纹理中获取法线数据
// 使用UnpackNormal()函数进行解码操作
float3 normalMap = UnpackNormal(tex2D (_NormalTex, IN.uv_NormalTex));

// 对光照模型应用新的法线
o.Normal = normalMap.rgb;
  • 完整代码如下:
Shader "CookbookShaders/Normal mapping"
{
    Properties 
    {
        _MainTint("Diffuse Tint", color) = (1,1,1,1)
        _NormalTex("Normal Map", 2D) = "bump"{}
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma target 4.0 
        #pragma surface surf Lambert

        // 将属性链接到CG程序
        half4 _MainTint;
        sampler2D _NormalTex;

        // 确保在struct中获得纹理的uv
        struct Input 
        {
            float2 uv_NormalTex;
        };
        
        void surf (Input IN, inout SurfaceOutput o) 
        {
            // 从法线贴图纹理中获取法线数据
            // 使用UnpackNormal()函数进行解码操作
            float3 normalMap = UnpackNormal(tex2D (_NormalTex, IN.uv_NormalTex));

            // 对光照模型应用新的法线
            o.Normal = normalMap.rgb;
            o.Albedo =  _MainTint.rgb;
            o.Alpha = _MainTint.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        下图展示了法线贴图着色器的效果: 

图4.2

4.3、实现原理...

        本章节中不会介绍法线函数的推导,这部分内容超出了本章范围。Unity已经为我们创建了函数,完成了所有工作。 这样我们就不用一遍又一遍地做了。 这也是为什么表面着色器是一种非常有效的着色器原因之一了。  

        UnpackNormal()函数的定义是在UnityCG.cginc文件里。UnityCG.cginc文件可以在Unity安装目录的Data文件夹中的找到它。当你在你的表面着色器中声明这个函数时,Unity会为你处理法线贴图,并给返回给你正确的数据类型,这样你就可以在你的逐像素关照函数中使用它了。它为我们节省了非常多的时间!  

        一旦你用UnpackNormal()函数处理了法线贴图,就相当于把它发送回你的SurfaceOutput结构体了,这样它就可以在光照函数中使用了。 这是通过o.Normal = normalMap.rgb;行完成的。 

4.4、更多内容

        你也可以添加一些属性来控制法线贴图的强度,这样能够让用户直观的去调节法线贴图的强度。 这个操作通过修改法线贴图中的x分量和y分量,最后将它们全部加在一起实现的。这是很容易就能够做到的。方法如下所示:

  • 步骤1:在properties代码块中添加另一个属性,并将其命名为_NormalMapIntensity,如下面的代码所示:
Properties 
{
    _MainTint("Diffuse Tint", color) = (1,1,1,1)
    _NormalTex("Normal Map", 2D) = "bump"{}

     // 添加下面的属性
    _NormalIntensity("Normal Map Intensity", Range(0,2)) = 1
}
  • 步骤2:在SubShader函数中声明该属性: 
// 将属性链接到CG程序
half4 _MainTint;
sampler2D _NormalTex;

// 在Subshader中声明该属性
float _NormalIntensity;
  • 步骤3:把计算后的法线贴图的x分量和y分量分别乘以 _NormalIntensity变量,并将该结果重新应用到法线贴图变量中去。现在你可以让用户在材质球检查器中调整法线贴图的强度了: 
// 从法线贴图纹理中获取法线数据
// 使用UnpackNormal()函数进行解码操作
float3 normalMap = UnpackNormal(tex2D (_NormalTex, IN.uv_NormalTex));

// 法线强度的计算
normalMap = float3(normalMap.x * _NormalIntensity, normalMap.y * _NormalIntensity, normalMap.z);
  • 完整代码如下:
Shader "CookbookShaders/Normal mapping - More"
{
    Properties 
    {
        // 添加这些属性
        _MainTint("Diffuse Tint", color) = (1,1,1,1)
        _NormalTex("Normal Map", 2D) = "bump"{}
        _NormalIntensity("Normal Map Intensity", Range(0,2)) = 1
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma target 4.0 
        #pragma surface surf Lambert

        // 将属性链接到CG程序
        half4 _MainTint;
        sampler2D _NormalTex;
        float _NormalIntensity;

        // 确保在struct中获得纹理的uv
        struct Input 
        {
            float2 uv_NormalTex;
        };
        
        void surf (Input IN, inout SurfaceOutput o) 
        {
            // 从法线贴图纹理中获取法线数据
            // 使用UnpackNormal()函数进行解码操作
            float3 normalMap = UnpackNormal(tex2D (_NormalTex, IN.uv_NormalTex));
            normalMap = float3(normalMap.x * _NormalIntensity, normalMap.y * _NormalIntensity, normalMap.z);

            // 对光照模型应用新的法线
            o.Normal = normalMap.rgb;
            o.Albedo =  _MainTint.rgb;
            o.Alpha = _MainTint.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        下图4.3显示了,法线贴图在调节不同强度属性上的显示结果。从左到右,法线强度值越来越大,左边法线强度是最弱的,右边的强度值是最大的。

图4.3

第5节. 在Unity编辑器中创建程序纹理

        比如有时候你想要创建一些动态的效果,例如在Unity运行的时候去修改它们的像素,或者动态的去创建一些纹理。这些通常被称为程序纹理效果(procedural texture effects)。 程序纹理是指:你可以创建一组二维性质的像素,并把它应用在新的纹理上(而不需要再使用二维软件或者图像应用程序手动的去创建一些纹理了)。然后,再将新纹理传递给着色器,以便在计算中使用。  

        这种技术主要用于,是在已经存在的纹理贴图上进行绘制。使用动态创建的纹理贴图,来与游戏玩家和游戏环境去建立很好的互动。它也可以用于贴花类型的效果上,或者用于创建在Shader功能中使用的程序形状。 大多情况下,我们游戏中都会这样的需求,就是需要创建一个新的纹理来填充一些程序模式,同时还需要在你的着色器中使用它。  

        创建动态纹理的过程确实依赖于C#脚本,用脚本来去处理纹理。在我们的着色器管线中,就一套好技术而言,这是你应该会的。下面让我们看看怎样写这个脚本,并且把动态创建的纹理发送到表面着色器上。 

5.1、准备工作

        你需要按照以下的步骤来实现这项技术:  

  1. 在你的Unity项目中创建一个新的c#脚本,并命名为proceuraltexture。
  2. 在你的场景中创建一个空的GameObject,将其位置的值归零,并将proceuraltextures .cs脚本赋予给这个空的GameObject。  
  3. 接下来,创建一个新的着色器,一个新的材质球,和一个新的对象。并分别命名,然后保存它们。合理的命名,方便我们很容易找到它们。
  4. 所有的这些设置完成之后,我们准备开始创建代码,这会生成一个抛物线型的形状,然后把它应用到纹理上,并给纹理着色器。 在方法的最后,你会看到你创建的程序纹理,展示效果如下图5.1所示: 

图5.1

5.2、如何实现...

  • 步骤1:创建一个变量来控制纹理的高度和宽度,并创建一个Texture2D变量来存储我们生成的纹理。 在脚本运行时,我们还需要一些私有变量来存储一些数据。
#region Public Variables
// 这些值可以让我们控制 长度/高度
// 并查看所生成的纹理
public int widthHeight = 512;
public Texture2D generatedTexture;
#endregion

#region Public Variables
// 这些变量是脚本私有的变量 
// 并不是公开的
private Material currentMaterial;
private Vector2 centerPosition;
#endregion
  • 步骤2:在脚本的Start () 函数中,我们首先需要检查这个脚本所赋予的对象上是否添加了材质球。 如果添加了材质球,我们就会调用自定义函数GenerateParabola(),并将返回值传递给我们的Texture2D变量:
// Use this for initialization

void Start () 
{

	// 用来判断在这个变换上是否有赋予了材质球
	// 如果没有赋予材质球,就无法生成纹理
	if(currentMaterial == null)
	{
		//currentMaterial = transform.renderer.sharedMaterial;
		Renderer renderer = gameObject.GetComponent<Renderer>();
		if(renderer == null)
		{
			Debug.LogWarning("Cannot find a renderer on: ");
		}

		currentMaterial = renderer.sharedMaterial;
		
	}

	// 生成程序纹理
	if(currentMaterial)
	{
		// 生成抛物线纹理
		centerPosition = new Vector2(0.5f, 0.5f);
		generatedTexture = generateParabola();

		// 给这个变换的材质球赋值
		currentMaterial.SetTexture("_MainTex", generatedTexture);
	}
	
}
  • 步骤3:然后,我们需要声明我们的自定义函数,它会为我们实现我们想要的效果:
private Texture2D generateParabola()
{

}
  • 步骤4:最后,把在纹理中计算抛物线的算法放到我们自定义的函数中。如果没有理解它的含义,不要担心; 在下一节中,我们将介绍每一行代码。
private Texture2D generateParabola()
{
	// 创建一个新的2D纹理
	Texture2D proceduralTexture = new Texture2D(widthHeight, widthHeight);

	// 得到贴图的中心点
	Vector2 centerPixelPosition = centerPosition * widthHeight;

	// 循环遍历新纹理上的每个像素,并确定其与中心点的距离
	// 根据该距离来分配像素值
	for(int x = 0; x < widthHeight; x++)
	{
		for(int y = 0; y < widthHeight; y++)
		{
			//获取从纹理中心到我们当前选定像素的距离
			Vector2 currentPosition = new Vector2(x,y);
			float pixelDistance = Vector2.Distance(currentPosition, centerPixelPosition) / (widthHeight * 0.5f);

			// 将值进行反转,确保我们不会得到任何负数的值
			// 或者大于1的值
			pixelDistance = Mathf.Abs(1-Mathf.Clamp(pixelDistance, 0f, 1f));

			// 创建一个新的颜色值
			Color pixelColor = new Color(pixelDistance, pixelDistance, pixelDistance, 1.0f);
			proceduralTexture.SetPixel(x,y,pixelColor);
		}
		
	}
	// 最后强制这些新像素的应用程序应用到纹理中
	proceduralTexture.Apply();

	// 将纹理返回到主程序
	return proceduralTexture;

}
  • 完整代码如下
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

 

public class ProceduralTexture : MonoBehaviour {

	#region Public Variables
	// 这些值可以让我们控制长度或高度
	// 并查看所生成的纹理
	public int widthHeight = 512;
	public Texture2D generatedTexture;
	#endregion

	#region Public Variables
	// 这些变量是脚本私有的变量 
	// 并不是公开的
	private Material currentMaterial;
	private Vector2 centerPosition;
	#endregion

	// Use this for initialization
	void Start () {

		// 用来判断在这个变换上是否有赋予了材质球
		// 如果没有赋予材质球,就无法生成纹理
		if(currentMaterial == null)
		{
			//currentMaterial = transform.renderer.sharedMaterial;
			Renderer renderer = gameObject.GetComponent<Renderer>();
			if(renderer == null)
			{
				Debug.LogWarning("Cannot find a renderer on: ");
			}

			currentMaterial = renderer.sharedMaterial;
			
		}

		// 生成程序纹理
		if(currentMaterial)
		{
			// 生成抛物线纹理
			centerPosition = new Vector2(0.5f, 0.5f);
			generatedTexture = generateParabola();

			// 给这个变换的材质球赋值
			currentMaterial.SetTexture("_MainTex", generatedTexture);
		}
		
	}
	
	// Update is called once per frame
	void Update () {
		
	}


	private Texture2D generateParabola()
	{
		// 创建一个新的2D纹理
		Texture2D proceduralTexture = new Texture2D(widthHeight, widthHeight);

		// 得到贴图的中心点
		Vector2 centerPixelPosition = centerPosition * widthHeight;

		// 循环遍历新纹理上的每个像素,并确定其与中心点的距离
		// 根据该距离来分配像素值
		for(int x = 0; x < widthHeight; x++)
		{
			for(int y = 0; y < widthHeight; y++)
			{
				//获取从纹理中心到我们当前选定像素的距离
				Vector2 currentPosition = new Vector2(x,y);
				float pixelDistance = Vector2.Distance(currentPosition, centerPixelPosition) / (widthHeight * 0.5f);

				// 将值进行反转,确保我们不会得到任何负数的值
				// 或者大于1的值
				pixelDistance = Mathf.Abs(1-Mathf.Clamp(pixelDistance, 0f, 1f));

				// 创建一个新的颜色值
				Color pixelColor = new Color(pixelDistance, pixelDistance, pixelDistance, 1.0f);
				proceduralTexture.SetPixel(x,y,pixelColor);
			}
			
		}
		// 最后强制这些新像素的应用程序应用到纹理中
		proceduralTexture.Apply();

		// 将纹理返回到主程序
		return proceduralTexture;

	}
}

注意:在start()函数中 currentMaterial = transform.renderer.sharedMaterial; 里面的写法已经被弃用,Unity会弹出报错:error CS0619: `UnityEngine.Component.renderer' is obsolete: `Property renderer has been deprecated. Use GetComponent<Renderer>() instead. (UnityUpgradable)' 所以我们使用了GetComponent<Renderer>()做了替代。改后的写法如下:Renderer renderer = gameObject.GetComponent<Renderer>();】

5.3、实现原理...

        在脚本start()这个函数中,它首先是检查了一下场景中特定的物体上是否赋予了一个我们可以分配纹理的材质球。如果赋予了,我们就会把currentMaterial这个变量赋值为transform.renderer.sharedMaterial。最后返回的是一个材质球。

        然后转到下一个if() 语句,用它来判断是否赋予了材质球或者材质球是否有效。 如果材质球是有效的,我们就调用GenerateParabola() 函数,它会为我们返回一个Texture2D。  

        一旦程序被移动到了GenerateParabola()函数中,它就会使用新的Texture2D() 构造函数和传入的widthHeight变量开始创建一个新的纹理。这个操作的结果是创建一个空纹理,并且这个空纹理允许我们把widthHeight方形中的每一个像素都赋予像素颜色。

        对于新的纹理,我们计算出了中心像素的位置并把它存储在了centerPixelPosition变量中。  

        然后我们用到了两个for循环,循环遍历了我们创建的新空纹理中的每个像素。 如果您不熟悉c# 的for循环,请参阅:Iteration statements -for, foreach, do, and while - C# reference | Microsoft LearnC# iteration statements (for, foreach, do, and while) repeatedly execute a block of code. You use those statements to create loops or iterate through a collection.icon-default.png?t=O83Ahttp://msdn.microsoft.com/en-us/library/ch45axte.aspx        然后,对于当前在循环中选择的Vector2(x,y)中的每个像素,我们使用Vector2. distance()函数测量其与中心像素的距离。 这个函数将为我们返回一个浮点值。 例如,如果我们创建一个512 x 512的纹理,循环中的当前像素位置等于Vector2(32,32),我们将得到距离值为316.78。 即到中心的像素距离为(32,32)。  

        之后我们需要重新映射像素距离,使其在0到1的范围内。所以它可以作为颜色值来使用(在Unity中使用0.0到1.0的值作为颜色值)。为了实现这种重映射,我们所要做的就是将距离值除以纹理宽度或高度的一半。 在这种情况下,我们把距离除以256,因为它是512的一半。 所以,如果距离是316.78,就像我们在前面的例子中看到的,我们得到的值是1.23。  

        现在,我们需要确保没有得到高于1.0或低于0.0的值,因此我们使用Mathf.Clamp()函数,该函数允许我们将值固定到作为参数传入的限制范围内。 我们传入0和1来确保我们得到一个标准化的值。  

        最后,我们通过使用1减去当前值来反转颜色,然后将最终值传递到新的颜色变量的通道中。 如下图5.2所示: 

图5.2

 1.3、实现原理

现在,你已经了解了一点如何使用矢量数学来生成像素值,考虑一下你所生成并且存储到纹理中的所有其他类型的数据。以下代码演示了通过查看世界向量的点积和从图像中心开始的像素方向,可以生成的其他类型的数据。

  • 步骤1:以下是在纹理中心周围创建环形的数学方法:
// 循环遍历新纹理上的每个像素,并确定其与中心点的距离
// 根据该距离来分配像素值
for(int x = 0; x < widthHeight; x++)
{
	for(int y = 0; y < widthHeight; y++)
	{

        // // 创建环形状的数学方法
		// Get the distance from the center of the texture to
		// our currently selected pixel
		Vector2 currentPosition = new Vector2(x,y);
		float pixelDistance = Vector2.Distance(currentPosition, centerPixelPosition) / (widthHeight * 0.5f);
		pixelDistance = Mathf.Abs(1-Mathf.Clamp(pixelDistance, 0f, 1f));
		pixelDistance = (Mathf.Sin(pixelDistance * 30.0f) * pixelDistance);  


		// 创建一个新的颜色值
		Color pixelColor = new Color(pixelDistance, pixelDistance, pixelDistance, 1.0f);
		proceduralTexture.SetPixel(x,y,pixelColor);
	}

 
  • 步骤2:以下是创建像素方向的点积与右侧和向上世界向量的数学计算:
// you can also do some more advanced vector calculations to achieve
// other types of data about the model itself and its uvs and
// pixels
Vector2 pixelDistance = centerPixelPosition - currentPosition;
pixelDistance.Normalize();
float rightDirection = Vector2.Dot(pixelDistance, Vector3.right); 
float leftDirection = Vector2.Dot(pixelDistance, Vector3.left); 
float upDirection = Vector2.Dot(pixelDistance, Vector3.up); 
  • 步骤3:以下是创建像素方向角度与世界方向之比的数学运算:
// you can also do some more advanced vector calculations to achieve
// other types of data about the model itself and its uvs and
// pixels
Vector2 pixelDistance = centerPixelPosition - currentPosition;
pixelDistance.Normalize();
float rightDirection = Vector2.Angle(pixelDistance, Vector3.right) / 360; 
float leftDirection = Vector2.Angle(pixelDistance, Vector3.left) / 360; 
float upDirection = Vector2.Angle(pixelDistance, Vector3.up) / 360; 

        下图5.3中显示了使用不同矢量和角度计算处理像素的不同结果:

图5.3

第6节. Photoshop色阶特效

        如果你曾经做过处理二维图像相关的事情,比如:类似照相馆中人物相片的调整、制作游戏纹理贴图、数字绘画等等,我相信你清楚色阶的重要性,它可以以全局调整你的整个画面。 是的,在你的着色器中是完全可以实现出类似于Photoshop中的效果的。

        你在Photoshop中找到的所有不同的图像编辑工具和混合模式都是用一组数学运算来描述的。 最后,我们将像素值与其他值进行乘法、加法、减法和比较,最终得到一个返回值。 然后,这个返回值成为正在编辑的图像中的新像素的颜色。  

        虽然我们可以写一整本书来介绍 Photoshop 效果的不同的数学方法,使用不同数学的计算方式来展示它。但在这里我们暂且只关注色阶。 我们将在第10章,“使用Unity渲染纹理来实现屏幕特效” 介绍更高级的混合模式。 

6.1、准备工作

        首先我们需要创建一个新的空场景,在场景中创建一个游戏对象(GameObject),用来展示我们实现的效果。然后再创建一个新的着色器和材质球,并将其赋予给场景中的对象。 我们还需要一张纹理来测试我们色阶的代码。 你也可以使用本书中提供的纹理图像来测试。

6.2、如何实现... 

  • 步骤1:将以下属性添加到新的着色器中:
Properties

{
    _MainTex("Base (RGB)", 2D) = "white"{}

    // 添加输入的色阶值属性
    _inBlack("Input Black", Range(0,255)) = 0
    _inGamma("Input Gamma", Range(0,2)) = 0.61
    _inWhite("Input White", Range(0,255)) = 255

    // 添加输出的色阶值属性
    _outWhite("Output White", Range(0,255)) = 255
    _outBlack("Output Black", Range(0,255)) = 0
}
  • 步骤2:确保在CGPROGRAM语句中声明这些变量:  
// 将这些属性添加到CGPROGRAM中
float _inBlack;
float _inGamma;
float _inWhite;
float _outWhite;
float _outBlack;
  • 步骤3:创建一个新变量,只存储当前_MainTex纹理的红色通道:
// 创建一个变量
// 来储存主贴图纹理的通道
float outRPixel;
  • 步骤4:由于tex2D()函数提供给我们的值在0.0到1.0的范围内,我们需要将该范围重新映射到0.0到255.0。  
// 将0到1的范围重映射为0到255
outRPixel = (c.r * 255);

  • 步骤5:然后,我们减去我们的输入黑色,当滑块滑向255.0时,使所有像素变为黑色:
// 减去给出的黑色值
outRPixel = max(0, outRPixel - _inBlack);

  • 步骤6:然后我们增加所有像素的白色,当我们把白色输入(input White)的滑动条朝0.0的方向去滑动时,得到的结果是_inGamma(input Gamma)次幂:
// 增加每个像素里的白色值
outRPixel = saturate(pow(outRPixel / (_inWhite - _inBlack), _inGamma));
  • 步骤7:最后,我们用新的像素值乘以输出的白色减去输出的黑色,然后将新的像素值重新映射到0.0到1.0的范围:  
// 改变最终的白色像素点和黑色像素点
// 并将它们从0~255重映射到0~1的范围
outRPixel = (outRPixel * (_outWhite - _outBlack) + _outBlack) / 255.0;
  • 完整代码如下:
Shader "CookbookShaders/Photoshop levels effect"
{
    Properties 
    {
        _MainTex("Base (RGB)", 2D) = "white"{}

        // 添加输入的色阶值属性
        _inBlack("Input Black", Range(0,255)) = 0
        _inGamma("Input Gamma", Range(0,2)) = 0.61
        _inWhite("Input White", Range(0,255)) = 255

        // 添加输出的色阶值属性
        _outWhite("Output White", Range(0,255)) = 255
        _outBlack("Output Black", Range(0,255)) = 0
    }
 SubShader 
    { 
        Tags { "RenderType"="Opaque" }
        LOD 200
        CGPROGRAM
        #pragma surface surf Lambert

        // 将这些属性添加到CGPROGRAM中
        float _inBlack;
        float _inGamma;
        float _inWhite;
        float _outWhite;
        float _outBlack;
        sampler2D _MainTex;

        // 确保在struct中获得纹理的uv
        struct Input 
        {
            float2 uv_MainTex;
        };

        void surf (Input IN, inout SurfaceOutput o) 
        {
            float4 c = tex2D (_MainTex, IN.uv_MainTex);

            // 创建一个变量
            // 来储存主贴图纹理的通道
            float outRPixel;
            
            // 将0到1的范围重映射为0到255
            outRPixel = (c.r * 255);
            
            // 减去给出的黑色值
            outRPixel = max(0, outRPixel - _inBlack);

            // 增加每个像素里的白色值
            outRPixel = saturate(pow(outRPixel / (_inWhite - _inBlack), _inGamma));
            
            // 改变最终的白色像素点和黑色像素点
            // 并将它们从0~255重映射到0~1的范围
            outRPixel = (outRPixel * (_outWhite - _outBlack) + _outBlack) / 255.0;

            float outRPixel_g;
            outRPixel_g = (c.g * 255);
            outRPixel_g = max(0, outRPixel_g - _inBlack);
            outRPixel_g = saturate(pow(outRPixel_g / (_inWhite - _inBlack), _inGamma));
            outRPixel_g = (outRPixel_g * (_outWhite - _outBlack) + _outBlack) / 255.0;

            float outRPixel_b;
            outRPixel_b = (c.b * 255);
            outRPixel_b = max(0, outRPixel_b - _inBlack);
            outRPixel_b = saturate(pow(outRPixel_b / (_inWhite - _inBlack), _inGamma));
            outRPixel_b = (outRPixel_b * (_outWhite - _outBlack) + _outBlack) / 255.0;

            c.rgb = float3(outRPixel, outRPixel_g, outRPixel_b);

            o.Albedo =  c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
 FallBack "Diffuse"
}

        下图6.1展示了通过着色器将色阶应用于纹理的最终效果,左图是原始图像,右图是使用色阶调节之后的效果: 

图6.1 

6.3、实现原理...

        在Shader中,surf函数首先使用tex2D()函数对颜色纹理进行采样,并将其存储在一个名为c的变量中。此时,我们想要开始在单个通道上工作并修改每个通道的像素。 为此,我们创建了一个名为outpixel的新变量,并将其值赋给c.r * 255.0。 这会将取值范围从0.0到1.0变为0.0到255.0。

        然后程序获取当前像素值后再减去_inBlack属性值,以便使像素值变暗。 通过使用max()函数,我们还确保了减法后的值不低于0.0,该函数的作用是为我们提供了在两个值中取最大值。 

函数 描述
max( a, b )   这将返回a和b的最大值

        现在我们要用修改后的像素值除以新的白色像素值。我们可以通过从_inwhitvalue中减去_inBlack值来获得新的白色像素值。我们使用了一个比较简单的方法来提高像素值的亮度。然后这个提升了亮度的这个像素值我们设它叫做x,然后把_inGamma设它叫做y,它们之间的关系就是x的y次幂(就是提高亮度像素的_inGamma次幂)。这样你就可以去移动当前像素的中点值了。

        最后,我们使用_outWhite和_outBlack再次修改像素,这样您就可以对最小像素值以及最大像素值进行最终的全局控制。然后将该结果除以255.0,使其重新映射回0.0到1.0的范围内。  

        我们将最后的结果传递给o.Albedo作为最后的漫反射颜色。当你在材质球检查器选项卡中使用滑动条时,你会注意到你对纹理的对比度和亮度有很多控制。 

1.4、更多内容

        我相信你已经注意到了,在我们的Shader中有很多重复的代码。 实际上,我们可以在Shader中创建一个自定义函数来清理Shader代码。 这将有助于保持我们的思维逻辑清晰,还会提高我们着的开发效率。 请看下面的自定义函数:  

float GetPixelLevel(float pixelColor)
{
    float pixelResult;
    pixelResult = (pixelColor * 255);
    pixelResult = max(0, pixelResult - _inBlack);
    pixelResult = saturate(pow(pixelResult / (_inWhite - _inBlack), _inGamma));
    pixelResult = (pixelResult * (_outWhite - _outBlack) + _outBlack) / 255.0;
    return pixelResult;
}

        在我们的着色器中,通过使用这个自定义函数来处理最终的像素色阶。它让我们减少了Shader中surf函数中的代码数量,使用这个自定义的函数在surf函数中只需要三行代码就可以实现这个色阶的效果了。而不是之前需要15行代码才能实现。 这极大的为我们清理了代码的行数。如果想要修改色阶的计算,现在我们只需要在一个地方修改代码即可,而不是去三个地方分别修改。 

6.4、另请参阅

        更多关于色阶的信息可以在GPU Gems找到:

http://http.developer.nvidia.com/GPUGems/gpugems_ch22.htmlicon-default.png?t=O83Ahttp://http.developer.nvidia.com/GPUGems/gpugems_ch22.html


​这本书可以让你学习到如何使用着色器和屏幕特效让你的Unity工程拥有震撼的渲染画面。

作者:Kenny Lammers


Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐