纹理最初的目的是使用一张图片来控制模型的外观,使用纹理映射技术,可以把一张图黏在模型表面,逐纹素的控制模型颜色。美术人员建模时候,通常会在建模软件中利用纹理展开技术把纹理坐标存储在每个顶点。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维变量uv来表示,u是横向坐标v是纵向坐标因此纹理坐标也称为UV坐标。
顶点UV坐标的范围通常都被归一化到[0,1]之间,需要注意的是,纹理采样的纹理坐标不一定在这个范围,实际上,这种不在[0,1]的范围内的纹理坐标有时候非常有用,与之相关的是纹理的平铺模式,将决定引擎在遇到不在[0,1]范围内的纹理坐标如何采样。
Unity使用的纹理空间复合Opengl传统也就是 原点位于纹理左下角
单张纹理
Shader "Unlit/Single Texture"
{
Properties
{
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
_Color("Color",Color) = (1,1,1,1)
_MainTex("Main Tex",2D)="white" {}
}
SubShader
{
Pass
{
Tags {"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Specular;
float _Gloss; //数值比较大 使用float
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
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;
};
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
//法线从模型空间转到世界空间
o.worldNormal = UnityObjectToWorldNormal(v.normal);
//顶点模型空间转到世界空间
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//纹理采样
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*albedo*saturate(dot(worldNormal,worldLightDir));
//获取世界空间视角方向
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//获取新矢量h
fixed3 halfDir = normalize(worldLightDir+viewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
FallBack "Specular"
}
属性块增加一个_MainTex的纹理,2D是声明纹理的方式,使用了一个字符串后跟一个花括号作为初始值,"white"是内置纹理的名字,也就是一个全白的纹理,为了控制物体的整体色调,还声明了一个_Color属性。
cg代码中声明属性,需要为纹理类型声明一个float4类型的变量_MainTex_ST 其中_MainTex_ST的名字不是任意的,在Unity中需要使用纹理名_ST 来声明某个纹理属性。其中ST是缩放和平移的缩写,_MainTex_ST可以让我们得到该纹理的缩放和平移值。_MainTex_ST.xy存储的是缩放,_MainTex_ST.zw储存的是偏移值。这些值可以在材质面板调节
在顶点着色器中,使用纹理的属性值_MainTex_ST来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是首先使用缩放属性_MainTex_ST.xy对顶点坐标缩放,然后再使用平移属性_MainTex_ST.zw对结果进行偏移。Unity提供了一个内置宏TRANSFORM_TEX来帮我们计算上述过程。接受两个参数,第一个是顶点纹理坐标,第二个参数是纹理名。
片段着色器,使用Cg的tex2D函数对纹理采样,第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,返回计算得到的纹素值。使用采样结果和颜色属性_Color的乘积来作为材质的反射率albedo,并把他和环境光照相乘得到环境光部分,随后使用albedo来计算漫反射光照的结果,并和环境光照,高光反射相加后返回。
纹理属性
纹理属性的第一个属性是纹理类型。有Texture,Normalmap,Cubemap等高级纹理类型,之所以要为导入的纹理选择合适的类型,是因为只有这样才能让Unity知道我们的意图,为Unity Shader传递正确的纹理,并在一些情况下可以让Unity对纹理进行优化。
当把纹理类型设置为Texture后,下面会有一个Aplha from Grayscale复选框,如果勾选了,那么透明通道的值将会由每个像素的灰度值生成。
下面一个属性Wrap Mode。决定了当纹理坐标超过[0,1]范围将会如何被平铺。Wrap Mode有两种模式:一种是Repeat,如果纹理坐标超过了1,那么整数部分将会被舍弃,直接用小数
部分采样,这样的结果是纹理将会不断重复;另一种是Clamp,如果纹理坐标大于1,就会截取到1,小于0,就会截取到0,形成一个条形结构。
上图展示了纹理的平铺Tiling属性为(3,3)时候分别使用两种Wrap Mode的结果。左图使用了Repeat,这种模式下纹理不断重复。右图使用了Clamp,这种模式超过的范围将会被截取到边界值,形成一个条状结构。需要注意的是,想要让纹理的到这样的效果,必须使用纹理的属性,在UnityShader中对顶点纹理坐标进行相应的变换,也就是说代码包含类似下面的代码
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
也可以在材质面板中调整纹理的偏移量,下图给出了两种模式下调整纹理偏移量的一个例子
纹理导入面板的下一个属性是Filter Mode,决定了纹理由于变换而产生拉伸时候将采用哪种过滤模式,Filter Mode支持三种模式:Point Bilinear Trilinear。得到的图片滤波效果依次提升,但是消耗的性能也依次增大。纹理滤波会影响放大或者缩小纹理时得到图片的质量。例如当我们把一张64X64大小的纹理贴在一个512X512大小的平面上时候,就需要放大纹理
纹理缩小的过程比放大复杂得多,此时原纹理中的多个像素将会对应一个目标像素。纹理缩小更加的原因在于我们往往需要抗锯齿问题,一个最常见的放法就是使用多级渐远纹理技术。多级渐远纹理技术将原纹理提前用滤波技术处理来得到很多更小的图像,形成一个图像金字塔,每一层都是对上一层图像降采样的结果。在实时运行时,就可以快速得到结果像素,例如当物体远离相机,可以直接用较小的纹理,缺点是需要一定的空间用于存储这些多级渐远纹理,通常会多占33%的内存空间,典型的空间换取时间方法。开启多级渐远纹理可以在面板设置。如下图
下图给出了从一个倾斜角度观察一个网格结构的地板时,使用不同的Filter Mode得到的效果
在内部实现上,Point使用了最近邻滤波,在放大缩小时候,采样像素数目通常只有1个,因此图像看起来有种像素风格。而Bilinear使用了线性滤波,对于每个目标像素,会找到4个近邻像素,然后对他们进行线性插值混合得到最终像素,因此图像看起来像被模糊了。Trilinear几乎和Bilinear一样,只是Trilinear还会在多级渐近纹理之间混合。如果一张纹理没有使用多级渐近纹理技术,那么Trilinear和Bilinear就一样,通常我们会选择Bilinear滤波模式,需要注意的是,有时候我们不希望纹理看起来模糊,希望是像素风格这时候选择Point滤波。
最大尺寸和纹理模式。不同平台发布游戏时,要考虑目标平台的纹理尺寸和质量问题,Unity允许我们为不同目标平台选择不同的分辨率。理想情况下,导入的纹理可以是非正方向,但长宽大小应该是2的幂,如果使用了非2的幂的纹理,那么这些纹理就会占用更多的空间,而且GPU读取该纹理的速度也会有所下降,有些平台甚至不支持这种纹理,这时候Unity会在内部把他缩放成最近2的幂大小。出去性能和空间考虑,尽量使用2的幂大小的纹理。Format决定了Unity内部使用哪种格式来存储该纹理,如果把Texture Type设置为Advanced那么会有更多的Format供选择。使用的纹理格式精度越高,占用内存空间就越大,效果越好。当游戏内使用了大量TrueColor类型的纹理时,内存可能会迅速增加,因此对于不需要使用很高精度的纹理,尽量使用压缩格式。
凹凸映射
bump mapping,目的是使用一张纹理来修改模型表面的法线,以便模型提供更多的细节。这种方法不会真的改变模型顶点位置,只是让模型看起来像是凹凸不平的,但可以从模型的轮廓看出破绽。两种方法可以来凹凸映射:一种方法是使用一张高度纹理来模拟表面位移,然后得到一个修改后的法线值,这方法也称为高度映射;另一种方法则是使用一张纹理来直接存储表面法线,又称为法线映射。
高度纹理
高度图中存储的是强度值,用于表示模型表面局部的海拔高度。因此颜色越浅代表该位置的表面越向外凸起,颜色越深代表该位置越向内凹。这种方法的好处是直观的知道一个模型表面的凹凸情况,缺点是计算更加复杂,实时计算不能直接得到表面法线,而是由像素灰度值计算得到,消耗更多性能。高度图通常会和法线映射一起使用,用于给出表面凹凸的额外信息。也就是说,我们通常会使用发现映射来修改光照。
法线纹理
法线纹理存储的就是表面的法线方向。由于法线方向的分量范围在[-1,1]像素分量范围是[0,1] 因此需要一个映射,通常使用的映射就是
也就是说我们在shader中对法线纹理进行纹理采样后,还需要对结果进行一次反映射过程,以得到原来的法线方向,反映射实际上就是上面映射函数的逆函数
由于方向是相对于坐标空间来说的,那么法线纹理存储的法线方向在哪个坐标空间呢,对于模型顶点自带的法线,是定义在模型空间的,因此一种直接的想法就是将修改后的模型空间中的法线存储在一张纹理中,这种纹理称谓模型空间的法线纹理。然而实际制作中,往往会采取另一种坐标空间,模型的切线空间来存储法线,对于模型的每个顶点,都有一个属于自己的切线空间,这个切线空间的原点就是顶点本身,z轴是顶点的法线n,x轴是顶点的切线方向,y轴可由法线和切线叉乘得到,叫做副法线。如下图所示
这种纹理被称为是切线空间的法线纹理 下图分别给出了模型空间和切线空间下的法线纹理
图中可以看出,模型空间下的法线纹理看起来五颜六色,因为所有法线坐在的空间都是一个模型空间,每个点存储的法线方向是各异的,有的是(0,1,0)经过映射后存储到纹理中就对应了RGB(0.5,1,0.5)浅绿色。而切线空间下的法线纹理几乎全部是浅绿色。因为,每个法线所在的坐标空间不同,即表面每个点各自的切线空间。这种法线纹理其实就是存储了每个点各自的切线空间中法线扰动方向。也就是说,日过一个点的法线方向不变,那么在切线空间,新的法线方向就是z轴方向,即(0,0,1),经过映射后存储在纹理中就对应了RGB(0.5,0.5,1)浅蓝色。而这个颜色就是法线纹理中大片的蓝色,这些蓝色实际上说明顶点的大部分法线和模型本身法线一样,不需要改变。
总体来说,模型空间下的法线纹理更符合人类的直观认识,而且法线纹理本身也很直观,容易调整,因为不同法线方向就代表了不同的颜色。但美术人员往往更喜欢切线空间下的法线纹理。
模型空间存储法线优点
实现简单,直观,甚至不需要模型原始的法线和切线等信息。
纹理坐标的缝合处和尖锐的边角部分,可见的突变较少,提供平滑的边界。
切线空间有更多优点
自由度很高,模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建他的那个模型,应用到其他模型就不行,切线空间下的法线纹理记录的是相对法线信息,意味着把该纹理应用到一个
完全不同的网格上,也可以的到一个合理的结果。
可以UV动画。移动一个纹理的UV坐标实现凹凸效果,使用模型空间下的法线纹理会得到错误的结果。
可以重用法线纹理。
可以压缩。切线空间下的法线纹理中的法线z方向总是正方向,因此可以仅存储xy,推导得出z,模型空间由于法线纹理每个方向都可能,因此必须存储3个方向,不可压缩。
实践
计算光照模型中统一各个方向矢量所在的坐标空间。
一种切线空间计算光照,需要把光照方向,视角方向变换到切线空间
二种在世界空间下进行光照计算,此时需要把采样得到的法线方向变换到世界空间。
第一种往往优于第二种,因为在顶点着色器就完成对方向的统一,而第二种需要先纹理采样,变换需要在片元着色器实现。但通用性来看,第二种优于第一种,有时需要在世界空间下进行计算,例如Cubemap进行环境映射,需要使用世界空间下的反射方向对cubemap采样。如果同时需要进行法线映射,就需要把法线方向变换到世界空间。
1.切线空间下计算光照
在片元着色器中通过采样的刀切线空间下的法线,然后再和切线空间下视角光照等进行计算。
首先需要在顶点着色器把视角方向和光照方向转换到切线空间,需要知道变换矩阵。这个矩阵的逆矩阵从切线空间到模型空间变换矩阵容易求的,在顶点着色器中按切线,副切,法线顺序按列排列即可得到。如果一个变换仅存在平移和旋转,这个变换的逆矩阵就是他的转置,从切线空间到模型空间的变换满足这样的要求,因此从模型空间到切线空间的变换矩阵就是从切线空间到模型空间的转置矩阵,把切线,副切线,法线的顺序按行排列即可得到。
Shader "Unlit/Normal Map In Tangent Space"
{
Properties
{
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
_Color("Color",Color) = (1,1,1,1)
_MainTex("Main Tex",2D)="white" {}
_BumpMap("Normal Map",2D)="bump"{}
_BumpScale("Bump Scale",Float)=1.0
}
SubShader
{
Pass
{
Tags {"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Specular;
float _Gloss; //数值比较大 使用float
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent: TANGENT;
};
struct v2f
{
float4 pos : SV_POSITION;
float4 uv: TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
//对应的uv坐标
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_BumpMap);
TANGENT_SPACE_ROTATION;
//光源方向从模型空间转到切线空间
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
//转换视角方向
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//归一化
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);
//获取法线贴图的纹素
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
fixed3 tangentNormal;
//如果贴图属性没有修改为法线贴图
//tangentNormal.xy = (packedNormal.xy+2-1)*_BumpScale;
//tangentNormal.z = sqrt(1.0-saturate(dot(tangentNormal.xy,tangentNormal.xy)));
//如果贴图是法线贴图属性使用内置函数
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0-saturate(dot(tangentNormal.xy,tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*albedo*saturate(dot(tangentNormal,tangentLightDir));
//获取新矢量h
fixed3 halfDir = normalize(tangentLightDir+tangentViewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(tangentNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
FallBack "Specular"
}
在Properties语义中添加法线纹理属性,以及用于控制凹凸程度的属性
Properties
{
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
_Color("Color",Color) = (1,1,1,1)
_MainTex("Main Tex",2D)="white" {}
_BumpMap("Normal Map",2D)="bump"{}
_BumpScale("Bump Scale",Float)=1.0
}
为了和语义匹配在cg中声明对应的变量
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
输入结构体 切线空间是由顶点法线和切线构建出的坐标空间,因此需要得到顶点的切线信息
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent: TANGENT;
};
使用TANGENT语义描述float4类型的tangent变量,告诉Unity把顶点的切线方向填充到tangent变量,需要注意的是和法线方向normal不同,tangent类型是float4,因为需要使用tangent.w分量决定切线空间中的第三个坐标轴,副切线的方向性。
需要在顶点着色器中计算切线空间下的光照和视角方向,因此在输出结构体增加两个变量来存储变换后的方向
struct v2f
{
float4 pos : SV_POSITION;
float4 uv: TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
定义顶点着色器
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
//对应的uv坐标
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_BumpMap);
TANGENT_SPACE_ROTATION;
//光源方向从模型空间转到切线空间
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
//转换视角方向
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
由于使用了两张纹理,因此需要存储两个纹理坐标,把v2f的uv扩展为float4类型,xy存储_MainTex的纹理坐标,zw存储了_BumpMap的纹理坐标,这样可以减少寄存器的数目。然后,把模型空间下的切线方向,副切线方向和法线方向按列排列得到从模型空间到切线空间的变换矩阵rotation。在计算副切线时候,使用v.tangent.w和叉积结果相乘,这是因为和切线与法线垂直的方向两个,w决定了选哪一个。Unity也提供了内置宏TANGENT_SPACE_ROTATION帮助我们直接计算得到rotation矩阵。然后使用Unity内置函数ObjSpaceLightDir和ObjSpaceViewDir来得到模型空间下的光照和视角方向,再利用rotation从模型空间变换到切线空间。
fixed4 frag (v2f i) : SV_Target
{
//归一化
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);
//获取法线贴图的纹素
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
fixed3 tangentNormal;
//如果贴图属性没有修改为法线贴图
//tangentNormal.xy = (packedNormal.xy+2-1)*_BumpScale;
//tangentNormal.z = sqrt(1.0-saturate(dot(tangentNormal.xy,tangentNormal.xy)));
//如果贴图是法线贴图属性使用内置函数
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0-saturate(dot(tangentNormal.xy,tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*albedo*saturate(dot(tangentNormal,tangentLightDir));
//获取新矢量h
fixed3 halfDir = normalize(tangentLightDir+tangentViewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(tangentNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
首先利用tex2D对法线纹理_BumpMap采样。法线纹理中存储的是把法线映射之后的像素值,因此需要反映射回来。首先把packedNormal的xy分量映射回法线方向,然后乘以_BumpScale来的刀tangentNormal的xy分量。由于法线都是单位矢量,可以由tangentNormal.xy计算得到。
在Unity中,为了方便Unity对法线纹理存储优化,通常会把法线纹理标识Normal map。Unity会根据平台来选择不同的压缩方法,这时不能用上面的计算方法,需要使用Unity内置函数UnpackNormal得到正确法线方向
2.世界空间下计算
Shader "Unlit/Normal Map In World Space"
{
Properties
{
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
_Color("Color",Color) = (1,1,1,1)
_MainTex("Main Tex",2D)="white" {}
_BumpMap("Normal Map",2D)="bump"{}
_BumpScale("Bump Scale",Float)=1.0
}
SubShader
{
Pass
{
Tags {"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Specular;
float _Gloss; //数值比较大 使用float
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent: TANGENT;
};
struct v2f
{
float4 pos : SV_POSITION;
float4 uv: TEXCOORD0;
float4 TtoW0:TEXCOORD1;
float4 TtoW1:TEXCOORD2;
float4 TtoW2:TEXCOORD3;
};
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
//对应的uv坐标
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_BumpMap);
float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal,worldTangent)*v.tangent.w;
//计算从切线空间到世界空间的旋转矩阵
o.TtoW0 = float4(worldTangent.x,worldBinormal.x,worldNormal.x,worldPos.x);
o.TtoW1 = float4(worldTangent.y,worldBinormal.y,worldNormal.y,worldPos.y);
o.TtoW2 = float4(worldTangent.z,worldBinormal.z,worldNormal.z,worldPos.z);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//获取世界空间的位置
float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
//计算世界空间的光以及视角方向
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
//获取切线空间的切线
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
fixed3 bump;
bump = UnpackNormal(packedNormal);
bump.xy *= _BumpScale;
bump.z = sqrt(1.0-saturate(dot(bump.xy,bump.xy)));
//变换法线从切线空间到世界空间
bump = normalize(half3(dot(i.TtoW0.xyz,bump),dot(i.TtoW1.xyz,bump),dot(i.TtoW2.xyz,bump)));
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*albedo*saturate(dot(bump,lightDir));
//获取新矢量h
fixed3 halfDir = normalize(lightDir+viewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(bump,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
FallBack "Specular"
}
需要修改顶点着色器的输出结构体,增加从切线空间到世界空间的变换矩阵
struct v2f
{
float4 pos : SV_POSITION;
float4 uv: TEXCOORD0;
float4 TtoW0:TEXCOORD1;
float4 TtoW1:TEXCOORD2;
float4 TtoW2:TEXCOORD3;
};
一个插值寄存器最多只能存储float4大小的变量,对于矩阵这样的变量,按行拆分成多个变量存储。实际上对方向矢量的变换只需要使用3x3大小的矩阵,也就是说,每一行只需要使用float3类型的变量即可,为了充分利用插值寄存器的存储空间,把世界空间下的顶点位置存储在这些变量的w分量中。
修改顶点着色器,计算切线空间到世界空间的变换矩阵
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
//对应的uv坐标
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_BumpMap);
float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal,worldTangent)*v.tangent.w;
//计算从切线空间到世界空间的旋转矩阵
o.TtoW0 = float4(worldTangent.x,worldBinormal.x,worldNormal.x,worldPos.x);
o.TtoW1 = float4(worldTangent.y,worldBinormal.y,worldNormal.y,worldPos.y);
o.TtoW2 = float4(worldTangent.z,worldBinormal.z,worldNormal.z,worldPos.z);
return o;
}
上面计算了世界空间下的顶点切线,副切线,和法线的矢量表示,把他们按列摆放得到从切线空间到世界空间的变换矩阵,矩阵的每一行分别存在TtoW0 1 2 中,并把世界空间下顶点位置的xyz分量分别存储在这些的变量的w分量,充分利用寄存器的存储空间。
fixed4 frag (v2f i) : SV_Target
{
//获取世界空间的位置
float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
//计算世界空间的光以及视角方向
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
//获取切线空间的切线
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
fixed3 bump;
bump = UnpackNormal(packedNormal);
bump.xy *= _BumpScale;
bump.z = sqrt(1.0-saturate(dot(bump.xy,bump.xy)));
//变换法线从切线空间到世界空间
bump = normalize(half3(dot(i.TtoW0.xyz,bump),dot(i.TtoW1.xyz,bump),dot(i.TtoW2.xyz,bump)));
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*albedo*saturate(dot(bump,lightDir));
//获取新矢量h
fixed3 halfDir = normalize(lightDir+viewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(bump,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
首先从TtoW0 TtoW1 TtoW2的w分量获取顶点世界空间下的坐标,然后使用内置的UnityWorldSpaceLightDir和UnityWorldSpaceViewDir函数得到世界空间下的光照和视角方向。
接着使用内置的UnpackNormal函数对法线纹理进行采样和解码,并使用_BumpScale进行缩放,最后使用TtoW0,TtoW1,TtoW2存储的变换矩阵把法线变换到世界空间下,通过使用点乘操作来实现矩阵的每一行和法线相乘得到的。
视觉表现上,切线空间和世界空间下的光照计算几乎没有差别,在不需要环境映射的情况下,内置的UnityShader使用的是切线空间来进行法线映射和光照计算。之后内置的UnityShader都是用了世界空间来进行光照计算。
Unity中法线纹理类型
需要使用包含了法线映射的内置Unity Shader时,必须把使用的法线纹理按上面的方式标识成Normal map才能得到正确结果,因为Unity Shader都使用了内置的UnpackNormal函数来采样法线方向。那么把纹理设置成Normal map时候,可以让unity根据不同平台对纹理进行压缩,再通过UnpackNormal函数来针对不同的压缩格式对法线纹理进行正确采样
可以在UnityCG.cginc中找到内部实现:
代码中可以看出,某些平台使用了DXT5nm的压缩格式,因此需要针对这种格式进行解码。这种格式的法线纹理,纹素的a通道对应了法线的x分量,g通道对应了法线的y 纹理的r和b会被舍弃。法线的z分量可以由xy分量推导。使用这种格式可以减少法线纹理占用的内存空间。
Normal map里面的复选框Create from Grayscale用于从高度图生成法线纹理。高度图本身记录的是相对高度,是一张灰度图,白色表示相对更高,黑色表示相对更低。当把一张高度图导入Unity后,除了需要设置Normal map之外,还需要勾选这个,然后就可以把他和切线空间下的法线纹理同等对待了。
渐变纹理
纹理除了可以定义一个物体的颜色,还可以用于存储任何表面的属性,一种常见的用法就是使用渐变纹理来控制漫反射结果。
Shader "Unlit/RampTexture"
{
Properties
{
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
_Color("Color Tint",Color) = (1,1,1,1)
_RampTex("Ramp Tex",2D)="white" {}
}
SubShader
{
Pass
{
Tags {"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Specular;
float _Gloss; //数值比较大 使用float
fixed4 _Color;
sampler2D _RampTex;
float4 _RampTex_ST;
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;
};
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord,_RampTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed halfLambert = 0.5*dot(worldNormal,worldLightDir)+0.5;
fixed3 diffuseColor = tex2D(_RampTex,fixed2(halfLambert,halfLambert)).rgb*_Color.rgb;
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*diffuseColor;
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//获取新矢量h
fixed3 halfDir = normalize(worldLightDir+viewDir);
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
FallBack "Specular"
}
半兰伯特模型,通过对法线方向和光照方向的点积做一次0.5的缩放以及一个0.5的便宜来计算半兰伯特部分halfLambert。这样的得到的范围被映射到了0-1之间。之后使用halfLambert构建一个纹理坐标,并用这个纹理坐标对渐变纹理进行采样。由于RampTex实际上是一个一维纹理,纵向颜色不变,因此纹理坐标的u和v都是用了halfLambert。然后 从渐变纹理采样得到的颜色和材质颜色相乘,得到最终漫反射颜色。
需要把渐变纹理的平铺模式改为Clamp模式,防止纹理采样由于浮点数精度造成问题。
遮罩纹理
遮罩允许我们可以保护某些区域,免于被修改。比如在之前实现中,都是把高光反射到模型的所有地方,有时,希望模型表面某些区域的反光强,某些区域弱。为了得到更细腻的效果,可以使用一张遮罩纹理控制光照。另一种常见应用是制作地形时混合多张图片,使用遮罩控制如何混合这些纹理。
流程:通过采样得到遮罩纹理的纹素,然后使用其中的某个通道的值来与某种表面属性相乘,这样当通道为0,可以保护表面不受该属性影响。
Shader "Unlit/MaskTexture"
{
Properties
{
_Color("Color Tint",Color) = (1,1,1,1)
_MainTex("Main Tex",2D)="white" {}
_BumpTex("Bump Tex",2D)="bump" {}
_BumpScale("Bump Scale",Float) = 1.0
_SpecularMask("Specular Mask",2D)="white" {}
_SpecularScale("Specular Scale",Float) = 1.0
_Specular("_Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8,256)) = 20
}
SubShader
{
Pass
{
Tags {"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
sampler2D _SpecularMask;
//float4 _SpecularMask_ST;
float _SpecularScale;
float _Gloss; //数值比较大 使用float
fixed4 _Specular;
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpTex;
//float4 _BumpTex_ST;
float _BumpScale;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent : TANGENT;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 lightDir : TEXCOORD0;
float3 viewDir : TEXCOORD1;
float2 uv:TEXCOORD2;
};
v2f vert (a2v v)
{
v2f o;
//模型顶点从模型空间转换到投影空间
o.pos = UnityObjectToClipPos(v.vertex);
TANGENT_SPACE_ROTATION;
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);
fixed3 tangentNormal = UnpackNormal(tex2D(_BumpTex,i.uv));
tangentNormal.xy*=_BumpScale;
tangentNormal.z = sqrt(1.0-saturate(dot(tangentNormal.xy,tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex,i.uv).rgb*_Color.rgb;
//获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
fixed3 diffuseColor = albedo*max(0,dot(tangentNormal,tangentLightDir));
//计算漫反射
fixed3 diffuse = _LightColor0.rgb*diffuseColor;
//获取新矢量h
fixed3 halfDir = normalize(tangentLightDir+tangentViewDir);
fixed specularMask = tex2D(_SpecularMask,i.uv).r *_SpecularScale;
//计算高光
fixed3 specular = _LightColor0.rgb*_Specular.rgb*pow(saturate(dot(tangentNormal,halfDir)),_Gloss)*specularMask;
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
FallBack "Specular"
}
主纹理_MainTex 法线纹理_BumpTex 和遮罩纹理_SpecularMask定义了他们共同使用的属性变量_MainTex_ST 意味着材质面板修改主纹理的平铺系数和偏移系数会同时影响三个纹理
采样,这种方式节省需要存储的纹理坐标数目。实际游戏制作中,往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性。