第六章 基础纹理(1)
@
基础纹理
纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射(texture mapping)技术,我们可以把一张图“黏”在模型表面,逐纹素(texel)(纹素的名字是为和像素进行区分)地控制模型的颜色。
在美术人员建模的时候,通常会在建模软件中利用纹理展开技术把纹理映射坐标(texture-mapping coordinates)存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维变量(u,v)来表示,其中u是横向坐标,而v是纵向坐标。因此,纹理映射坐标也被称为uv坐标。
尽管纹理的大小可以是多种多样的,例如可以是256×256或者1024×1024,但顶点UV坐标的范围通常都被归一化到[0,1]范围内。需要注意的是,纹理采样时使用的纹理坐标不一定是在[0,1]范围内。实际上这种不在[0,1]范围内的纹理坐标有时会非常有用。与之关系紧密的是纹理的平铺模式,它将决定渲染引擎遇到不在[0,1]范围内的纹理坐标时如何进行纹理采样。
Unity使用的纹理空间是符合OpenGl传统的,也就是说,原点位于纹理左下角,如下图所示:
需要提醒读者注意的是,本章着重描述纹理采样的原理,因此实现的Shader往往并不能直接应用到实际的项目中(直接使用的话会缺少阴影、光照衰减等效果)。我们会在后面给出包含了纹理采样和完整光照模型的可真正使用的UnityShader。
1. 单张纹理
我们通常会使用一张纹理来代替物体的漫反射颜色。本节中,我们将学习如何在Unity Shader中使用单张纹理来模拟颜色。在学习完本节后,我们会得到类似于下图的效果:
1.1 实践
(1)为了使用纹理,我们需要在Properties语义块中添加一个纹理属性:
Properties{
_Color("Color Tint",Color)= (1,1,1,1)
_MainTex("Main Tex",2D) = "white" {}
_Specular("Specular", Color) = (1,1,1,1)
_Gloss("Gloss",Range(8.0,256)) = 20
}
上面的代码声明了一个名为_MainTex的纹理,在以前,我们已经知道2D是纹理的声明方式。我们使用一个字符串后跟一个花括号作为它的初始值。“white”是内置的纹理的名字,也就是一个全白的纹理。为了控制物体的整体色调,我们还声明了一个_Color属性。
(2)然后,我们在SubShader语义块中定义了一个Pass语义块。而且我们在Pass的第一行指明了该Pass的光照模式:
SubShader{
Pass{
Tags{"LightMode"="ForwardBase"}
}
}
LightMode标签是Pass标签中的一种,它用于定义该Pass在Unity的光照流水线中的角色。
(3)接着我们使用CGPROGRAM和ENDCG来包围住Cg代码片段,以定义最重要的顶点着色器和片元着色器代码。首先,我们使用#pragma指令来告诉Unity,我们定义的顶点着色器和片元着色器叫什么名字,在本例中,它们的名字分别是vert和frag:
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
(4)为了使用Unity内置的一些变量,如_LightColor0,还需要包含进Unity的内置文件Lighting.cginc:
#include "Lighting.cginc"
(5)我们需要在Cg代码片段中声明和上述属性相匹配的变量,以便和材质面板中的属性建立联系:
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;
与其它属性类型不同的是,我们还需要为纹理类型的属性声明一个float4类型的变量_MainTex_ST。其中,_MainTex_ST的名字不是任意起的。在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中ST是缩放(scale)和平移(translation)的缩写。_MainTex_ST可以让我们得到该纹理的缩放和平移(偏移)值,_MainTex_ST.XY存储的是缩放值,而_MainTex_ST.zw存储的是偏移值。这些值可以在材质面板的纹理属性中调节,如下图所示:
(6)接下来,我们需要定义顶点着色器的输入和输出结构体:
struct a2v{
float4 vertex:POSITION;
float3 normal:NORMAL;
float4 texcoord:TEXCOORD0;
};
struct v2f{
float4 pos:SV_POSITION;
float3 worldNormal:TEXCOORD0;
float3 worldPos:TEXCOORD1;
float2 uv:TEXCOORD2;
};
在上面代码中,我们首先在a2v结构体中使用TEXCOORD0语义声明了一个新的变量texcoord,这样Unity就会将模型的第一组纹理坐标存储到该变量中。然后,我们在v2f结构体中添加了用于存储纹理坐标的uv,以便在片元着色器中使用该坐标进行纹理采样。
(7)然后我们定义了顶点着色器:
v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
o.worldNormal=UnityObjectToWorldNormal(v.normal);
o.worldPos=mul(_Object2World,v.vertex).xyz;
o.uv = v.texcoord.xy*_MainTex_ST.xy+_MainTex_ST.zw;
//Or just call the built-in function
//o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
在顶点着色器中,我们使用纹理属性值_MainTex_ST来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性_MainTex_ST.xy对顶点纹理坐标进行缩放,然后再使用偏移属性_MainTex_ST.zw对结果进行偏移。Unity提供了一个内置宏TRANSFORM_TEX来帮我们计算上述过程。TRANSFORM_TEX是在UnityCG.cginc中定义的:
//Transform 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy*name##__ST.xy+name##_ST.zw)
它接受两个参数,第一个参数是顶点纹理坐标,第二个参数是纹理名,在它的实现中,将利用纹理名_ST的方式来计算变换后的纹理坐标。
(8)我们还需要实现片元着色器,并计算漫反射时使用纹理中的纹素值:
fixed4 frag(v2f i):SV_Target{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
//Use the texture to sample the diffuse color
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
fixed3 diffuse = _LightColor0.rgb*albedo*max(0,dot(worldNormal,worldLightDir));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir+viewDir);
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(max(0,dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient+diffuse+specular,1.0);
}
上面的代码首先计算了世界空间下的法线方向和光照方向。然后,使用Cg的tex2D函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,它将返回计算得到的纹素值。我们使用采样结果和颜色属性_Color的乘积来作为材质的反射率albedo,并把它和环境光相乘得到环境光部分。随后,我们使用albedo来计算漫反射光照的结果,并和环境光照、高光反射光照相加后返回。
(9)最后,我们为该Shader设置了合适的Fallback:
Fallback"Specular"
1.2 纹理属性
虽然很多资料把Unity的纹理映射描述的很简单——声明一个纹理变量,再使用tex2D函数采样。实际上,在渲染流水线中,纹理映射的实现远比我们想象的复杂。本文不会过多的涉及一些具体的实现细节,但要解释一些我们认为读者必须要知道的事情。在本节中,我们将关注Unity中的纹理属性。
在我们向Unity中导入一张纹理资源后,可以在它的材质面板上调整其属性,如下图所示。
纹理面板中的第一个属性是纹理类型。在本节中,我们使用的是Texture属性,在下面的法线纹理一节中,我们会使用Normal map类型。而在后面的章节中,我们还会看到Cubemap等高级纹理类型。我们之所以要为导入的纹理选择合适的类型,是因为只有这样才能让Unity知道我们的意图,为Unity Shader传递正确的纹理,并在一些情况下可以让Unity对该纹理进行优化。
当把纹理类型设置为Texture后,下面会有一个Alpha from Grayscale 复选框。如果勾选了它,那么透明通道的值将会由每个像素的灰度值生成。关于透明效果,我们会在下一章讲到,在这里我们不需要勾选它。
下面一个属性非常重要——Wrap Mode。它决定了当纹理坐标超过[0,1]范围后将如何平铺。Wrap Mode有两种模式:一种是Repeat,在这种模式下,如果纹理坐标超过了1,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复;另一种是Clamp,在这种模式下,如果纹理坐标大于1,那么将会截取到1,如果小于0,那么将会截取到0。下图给出了两种模式下平铺一张纹理的效果。
上图展示了在纹理的平铺(Tiling)属性为(3,3)时分别使用两种Wrap Mode的结果。作图使用了Repeat模式,在这种模式下纹理将会不断重复;右图使用了Clamp模式,在这种模式下超过范围的部分将会截取到边界值,形成一个条形结构。
需要注意的是,想要让纹理得到这样的效果,我们必须使用纹理的属性(例如上面的_MainTex_ST变量)在Unity Shader中对顶点坐标进行相应的变换。也就是说,代码中需要包含类似下面的代码:
o.uv = v.texcoord.xy*_MainTex_ST.xy+_MainTex_ST.zw;
//Or just call the bulit-in function
o,uv = TRANSFORM_TEX(v.texcoord,_MainTex);
我们还可以在材质面板中调整纹理的偏移量,下图给出了两种模式下调整纹理偏移量的一个例子:
上图展示了在纹理的偏移属性为(0.2,0.6)时分别使用两种Wrap Mode的结果,左图使用了Repeat模式,右图使用了Clamp模式。
纹理导入面板的下一个属性是Fliter Mode属性,它决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式。Fliter Mode支持3种模式:Point,Bilinear以及Trilinear。它们得到的图片滤波效果依次提升,但需要耗费的性能也依次增大。纹理滤波会影响放大或缩小纹理时得到的图片质量。例如,当我们把一张64×64大小的纹理贴在一个512×512大小的平面上时,就需要放大纹理。下图给出了3种滤波模式下的放大结果。
纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素会对应一个目标像素。纹理缩小更加复杂的原因在于我们往往需要处理抗锯齿问题,一个最常用的方法就是多级渐远纹理(mipmapping)技术,其中“mip”是拉丁文“multum in parvo”的缩写,它的意思是在一个小空间有许多东西。如同它的名字,多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像,形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,就可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用33%的内存空间,这是一种典型的空间换取时间的方法。在Unity中,我们可以在纹理导入面板时,首先将纹理类型(Texture Type)选择成Advanced,再勾选Generate Mip Maps即可开启多级渐远纹理技术。同时,我们也可以选择生成多级渐远纹理时是否使用线性空间(用于伽玛校正)以及采用的滤波器等。如下图所示:
下图给出了从一个倾斜的角度观察一个网格结构的地板时,使用不同Filter Mode(同时也使用了多级渐远纹理技术)得到的效果。
在内部实现上,Point模式使用了最近邻(nearest neighbor)滤波,再放大或缩小时,它的采样像素数目通常只有一个,一次图像看起来会有种像素风格的效果。而Bilinear滤波则使用了线性滤波,对于每个目标像素,他都会找到4个临近像素,然后对它们进行线性插值混合后得到最终像素,因此图像看起来被模糊了。而Trilinear滤波几乎是和Bilinear一样的,只是Trilinear还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么Trilinear得到的结果是和Bilinear就是一样的。通常我们会选择Bilinear滤波模式。需要注意的是,有时我们不希望纹理看起来是模糊的,例如对于一些类似棋盘的纹理,我们希望它就是像素风的,这时我们可能会选择Point模式。
最后,我们来讲一下纹理的最大尺寸和纹理模式。当我们在为不同平台发布游戏时,需要考虑目标平台的纹理尺寸和质量问题。Unity允许我们为不同目标平台选择不同的分辨率,如下图所示:
如果导入的纹理大小超过了Max Texture Size中的设置值,那么Unity将会把改纹理缩放为这个最大分辨率。理想情况下,导入的纹理可以是非正方形的,但长款的大小应该是2的幂,例如2,4,8,16,32,64等。如果使用了非2的幂大小(Non Power ofTwo,NPOT)的纹理,那么这些纹理往往会占用更多的内存空间,而且GPU读取改纹理的速度也会有所下降。有些平台甚至不支持这种NPOT纹理,这时Unity在内部会把它缩放成最近的2的幂的大小。出于性能和空间考虑,我们应尽量使用2的幂大小的纹理。
而Format决定了Unity内部使用哪种格式来存储该纹理。如果我们将Texture Type设置为Advanced,那么会有更多的Format供我们选择。这里不再依次介绍每种纹理模式,但需要知道的是,使用的纹理格式精度越高(例如使用Turecolor),占用的内存空间越大,得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间(如果开启了多级渐远纹理技术,也会增加纹理的内存占用)。当游戏中使用了大量Truecolor类型的纹理时,内存可能会迅速增加,因此对于一些不需要使用很高精度的纹理(例如用于漫反射颜色的纹理),我们应该尽量使用压缩格式。
.